# Bulkified Thinking > Writing, questioning and rethinking Salesforce development through architecture, engineering decisions, Apex, Lightning Web Components, Agentforce and AI. Public Ghost content for AI and LLM tooling. This file includes a bounded export of public pages first, then recent public posts. Append `.md` to any post or page URL to get the content in Markdown (for example, `/example-post.md`). ## Pages ### About this site URL: https://www.bulkifiedthinking.com/about/ Last updated: 2026-08-14T23:24:48.000Z Hi there! Any questions? Email me here: ansukhetskyi@gmail.com ### Privacy Policy URL: https://www.bulkifiedthinking.com/privacy/ Last updated: 2026-08-25T03:04:13.000Z I run Bulkified Thinking myself. I am Andrii Sukhetskyi, based in Ontario, Canada, and I am the person responsible for the personal information described here. If anything below is unclear or you want something done about your data, email [**ansukhetskyi@gmail.com**](mailto:ansukhetskyi@gmail.com) and you will be talking to me, not a ticket queue. This policy covers [www.bulkifiedthinking.com](https://www.bulkifiedthinking.com/). It does not cover other sites I link to. ## The short version If you just read articles, I collect anonymous traffic statistics and nothing else. If you subscribe, I have your email address. If you comment, other readers see your name and what you wrote. I do not sell anything about you, I do not run advertising, and there are no third-party tracking scripts on this site. ## What I collect, and when **When you subscribe.** Your email address, and your name if you choose to give one. This is stored by Ghost, the platform this site runs on. I use it to send the newsletter and to recognise you when you sign in. **When you comment.** Your name, your comment, and your profile picture if you have set one. Comments are public — that is the point of them. I can see the email address attached to your account. **When you send a tip.** Payments go through Stripe. Stripe handles your card details end to end; I never see or store a card number. What reaches me is the amount, the currency, and whether it succeeded. **When you read.** This site uses Ghost's own built-in analytics. It records page views, which page referred you, roughly which country you are in, and what kind of device you used. It is first-party — the data is not shared with an advertising network, and it does not follow you to other websites. There is no Google Analytics, no Tag Manager, no advertising pixel of any kind on this site. **When I send you an email.** Newsletter emails record whether the message was opened and whether links in it were clicked. This tells me which topics are worth writing more about. If you would rather not be measured this way, unsubscribing stops it entirely. **Your browser's own storage.** Two small values are saved in your browser and never sent anywhere: `appearance-preference`, which remembers whether you picked light or dark mode, and `posts-last-seen`, which remembers which short posts you have already scrolled past so the site can stop pointing at them. Clearing your browser storage deletes both, and nothing breaks. **Server logs.** Ghost's hosting keeps standard web server logs, including IP addresses, for security and troubleshooting. These rotate out on the host's own schedule. ## Other companies involved Only four, and each does one job: - **Ghost** hosts the site and stores subscriber accounts, comments and analytics. Ghost acts on my instructions as a data processor. - **Stripe** processes tips and holds the payment details I never see. - **YouTube** serves embedded videos, and only after you press play. Embeds use `youtube-nocookie.com`, so nothing is requested from YouTube until you actually choose to watch. - **GitHub** is contacted by *your browser*, not by my server, when you open the Open Source page — that is how the live star and fork counts appear. GitHub therefore sees your IP address on that page. I mention it because most sites in this position quietly do not. ## Why I am allowed to hold any of this If you are in the UK or the EU and want the formal grounds: consent for the newsletter, which you can withdraw at any time; legitimate interests for analytics, security and keeping the site working; and performance of a contract for your account and any tip you send. If you are in Canada, this is handled under PIPEDA, and the newsletter follows CASL — I only email people who asked for it, every message says who it is from, and every message has a working unsubscribe link. ## How long I keep it Subscriber records last as long as you are subscribed, plus a short window afterwards so an accidental unsubscribe can be undone. Comments stay until you or I delete them. Analytics are aggregated and are not tied to a named person. Payment records are kept as long as tax and accounting rules require. ## What you can ask me to do Ask for a copy of what I hold. Ask me to correct it. Ask me to delete it. Ask for it in a portable format. Withdraw consent. Object to how it is used. I honor these requests from everyone, wherever you live — it would be strange to give readers in one country fewer rights than readers in another. Email [**ansukhetskyi@gmail.com**](mailto:ansukhetskyi@gmail.com) and I will respond within 30 days. I will not charge you and I will not make the process deliberately tedious. Unsubscribing does not need an email to me at all: there is a link at the bottom of every newsletter, and it works immediately. ## Where your data goes Ghost and Stripe both operate outside Canada, so your information is stored and processed in other countries — and while it is there, it is subject to the laws of those countries. Both companies are bound by contract to protect it. If that is not acceptable to you, the practical answer is not to subscribe; you can read everything here without an account. ## Children This site is written for working software professionals. It is not aimed at children, and I do not knowingly collect information from anyone under 16\. If you believe a child has given me their details, email me and I will remove them. ## Changes When this policy changes materially, I will update the date at the top of the page, and for anything significant I will say so in the newsletter rather than hoping nobody notices. ## If you are unhappy Tell me first — most things are a misunderstanding and I would like the chance to fix it. If that does not resolve it, you can complain to the Office of the Privacy Commissioner of Canada at priv.gc.ca. Readers in the UK or EU may instead complain to their own national data protection authority. ### Disclaimer URL: https://www.bulkifiedthinking.com/disclaimer/ Last updated: 2026-08-25T03:07:31.000Z ## This is one person's experience Everything here is written by me, from what I have learned building on Salesforce. It reflects my own understanding at the time of writing — not a consensus, not an official position, and not the only reasonable way to do things. I get things wrong sometimes. When I find out, I fix the article and say what changed rather than quietly editing it. ## Written in a personal capacity I work in this industry, which is the reason I have anything useful to say. It is also the reason this section exists. **This site is entirely personal.** It is written on my own time, on my own equipment, and published on infrastructure I pay for myself. The views are mine alone and are not those of any employer, client, partner or customer, past or present. Nobody reviews, approves, sponsors or endorses what I publish here, and nothing here should be read as an official statement by any organization I have worked with. **Nothing published here is confidential or proprietary.** I do not write about any employer's or client's systems, data, source code, architecture, customers, internal documentation, roadmaps or commercial arrangements. Everything I discuss is drawn from publicly available platform documentation, publicly released features, open-source code, and general professional experience of the kind any practitioner accumulates. Where an article uses an example — an org shape, a data model, a failure, a code sample — it is constructed to illustrate the point. It is generic, or reconstructed, or invented outright. It is not a description of a real system belonging to anyone I have worked for, and any resemblance to one is a consequence of these problems being common across the industry, not of me describing a specific case. No employer or client is named anywhere on this site, and that is deliberate. ## Not advice This is technical writing, not professional advice. It is not legal, financial, security, compliance or architectural advice for your organization, because I know nothing about your organization. Before acting on anything here: read it critically, check it against the current official documentation, and test it in a sandbox. Your governor limits, your data volumes, your licensing, your integrations and your risk tolerance are not mine. A decision with real consequences deserves advice from someone who has actually looked at your situation. ## Code comes with no warranty Code samples are illustrations, not products. They are written to be readable rather than exhaustive, and they routinely leave out error handling, bulkification edge cases, security review and test coverage that production code needs. Use them, adapt them, learn from them — and take responsibility for what you deploy. I am not liable for what happens when code from an article reaches a production org. The open-source projects linked from here are separate, and each carries its own licence and its own warranty terms in its repository. ## Things go out of date Salesforce ships three releases a year and the AI tooling moves faster than that. An article is a snapshot of what was true when it was written. Check the publication date, and when it matters, check the official documentation before relying on any specific behavior, limit or API. ## Trademarks and affiliation Salesforce, Apex, Lightning Web Components, Agentforce, Experience Cloud and related marks are trademarks of Salesforce, Inc. Other names belong to their owners. **This site is not affiliated with, authorized by, endorsed by, or sponsored by Salesforce, Inc., or by any other company whose products are mentioned.** I use their names to describe what I am writing about, which is the only honest way to write about a platform. ## AI assistance I use AI tooling in my own work, and I write about doing so. Where it helps produce something published here, the thinking, the technical judgement, the testing and the final text remain mine. I do not publish generated content I have not verified myself. ## Sponsorship There is none. No article on this site is paid for, sponsored, or written in exchange for anything, and there are no affiliate links. If that ever changes, it will be disclosed clearly on the article itself, not buried on this page. ## External links Links point at documentation, repositories and other people's writing because they are worth reading. I do not control them and cannot vouch for what they say tomorrow. ## Questions If something here reads as though it crosses a line, tell me — [**ansukhetskyi@gmail.com**](mailto:ansukhetskyi@gmail.com) — and I will look at it properly. ### Terms of Use URL: https://www.bulkifiedthinking.com/terms/ Last updated: 2026-08-25T03:09:23.000Z Bulkified Thinking is run by me, Andrii Sukhetskyi, from Ontario, Canada. By reading, subscribing or commenting you agree to what follows. It is short on purpose. ## What this site is A personal publication about building on Salesforce — architecture, engineering practice, AI-assisted development, and the open-source tooling that came out of that work. It is written by one person, in his own time. ## Reading and sharing Read as much as you like. Quote me, link to me, forward an article to a colleague, or put an extract in a talk or an internal document — all fine, as long as you credit Bulkified Thinking and link back to the original. What is not fine is republishing whole articles as your own, or feeding this site's contents into a commercial dataset or model without asking. If you want to translate, syndicate or reprint something in full, email **ansukhetskyi@gmail.com**. The answer is usually yes. ## Code samples Code shown inside articles is there to explain an idea. Use it in your own projects freely, including at work. It comes with no warranty and it has not been tested against your org — read it, understand it, and run it in a sandbox before it goes anywhere near production. The open-source projects linked from this site are governed by the licence in each repository, not by this page. Where the two disagree, the repository licence wins. ## Comments Comments require a free account, mostly to keep the noise down. Post what you actually think, including that I am wrong. Disagreement is the reason the comments exist. What gets removed is harassment, spam, marketing, anything illegal, anything that reveals someone else's confidential or personal information, and anything posted under a false identity to mislead people. You keep ownership of your comments. By posting one you give me permission to display it on the site and to quote it in a follow-up article or the newsletter, with attribution to the name on your account. I may edit or delete comments, and I may close an account, without notice — this is a personal site, not a public forum with a right of appeal. ## Subscribing The newsletter is free. Unsubscribe whenever you want using the link in any email. I may change what the newsletter covers, how often it arrives, or stop sending it altogether. ## Tips If you send a tip, it is a voluntary gift and not a purchase. Nothing is unlocked by it, no service is owed in return, and tips are non-refundable unless the law says otherwise. Payments are handled by Stripe under their terms. ## Trademarks Salesforce, Apex, Lightning Web Components, Agentforce, Experience Cloud and related marks are trademarks of Salesforce, Inc. Other product and company names mentioned here belong to their respective owners. **This site is not affiliated with, authorised by, endorsed by, or sponsored by Salesforce, Inc. or any other company whose products are discussed.** Names are used descriptively, to say what the article is actually about. ## Links to other places Links to documentation, repositories and other people's writing are there because they are useful. I do not control those sites and I am not responsible for what they contain or what they do with your data once you arrive. ## No warranty This site is provided as is. I write carefully and correct things when I get them wrong, but I do not promise that everything here is accurate, current, or right for your situation. Platforms change, and an article that was correct when published may not be a year later. Nothing here is professional advice. If a decision carries real financial, legal or operational risk, get advice that accounts for your specific circumstances. ## Limits on liability To the fullest extent the law allows, I am not liable for any indirect, incidental, special or consequential loss arising from your use of this site or anything you found on it — including lost data, lost revenue, or a deployment that went badly. Where liability cannot be excluded, it is limited to the greater of the amount you have paid me in the last twelve months or CAD $100. Some jurisdictions do not allow these exclusions, in which case they apply to you only as far as the law permits. ## Your responsibility If your use of this site causes a claim against me — for example because of something you posted in a comment — you agree to cover the reasonable costs of dealing with it. ## Governing law These terms are governed by the laws of the Province of Ontario and the federal laws of Canada that apply there. Disputes go to the courts of Ontario. If you are a consumer somewhere with stronger local protections, this does not take those away from you. ## Changes I will update these terms from time to time and change the date at the top when I do. Continuing to use the site after a change means you accept the new version. ## Contact ansukhetskyi@gmail.com ## Posts ### From AIForce to SIForce: What’s New with Winter ‘27 URL: https://www.bulkifiedthinking.com/posts/from-aiforce-to-siforce-whats-new-with-winter-27/ Last updated: 2026-10-12T12:03:00.000Z As you might have heard, on Friday Marc Benioff officially renamed AIForce to SIForce. This follows the U.S. administration’s executive order that tells federal agencies to replace “Artificial Intelligence” with “Super Intelligence”. (And by “officially” I mean a post on X) In case you missed it, AIForce was announced at Dreamforce 2026\. It’s mostly a marketing layer that combines capabilities such as Headless 360, hosted MCP servers, APIs, skills and plugins. Everything we’ve known about for quite some time just got an extra layer on top and was packaged together as AIForce. Now AIForce becomes SIForce, and we should expect other products to be renamed around the Super Intelligence name as well. ## Winter ‘27 Is Live As for the more exciting news, the last release weekend was October 10, so Winter ‘27 has now officially landed in all production environments. If you missed what’s included, check out this post: [Salesforce Winter ‘27 Is Here: All Developers Need to Know](https://www.bulkifiedthinking.com/salesforce-winter-27-is-here-all-developers-need-to-know/) Users have already found two issues in the new release that you need to be aware of. ### [CustomObject metadata retrieval fails when Profile Filtering is enabled under a high assurance session](https://help.salesforce.com/s/issue?id=a02g700000BJ0kLAAT&title=customobject-metadata-retrieval-fails-when-profile-filtering-is-enabled-under-high-assurance-session&ref=bulkifiedthinking.com) Profile Filtering has been an optional setting since Winter ‘21, but Winter ‘27 now turns it on through a release update. If **Profile Filtering** is enabled under User Management Settings in your org, and **Manage Profiles and Permission Sets** is set to **Raise session to high assurance** under Identity Verification settings, your Metadata API calls that retrieve CustomObject might fail with a Salesforce error. For example, a simple retrieve like this one is enough to run into it: ```xml Account Invoice__c CustomObject 68.0 ``` ```bash sf project retrieve start --manifest manifest/package.xml --target-org production ``` The same applies to any deployment tool or CI pipeline that uses the Metadata API `retrieve()` call behind the scenes. For now, the workaround is to turn off Profile Filtering in your production org and wait for a fix. ### [“Insufficient Privileges” error on the MFA registration page](https://help.salesforce.com/s/issue?id=a02g700000BGsvlAAD&title=insufficient-privileges-error-occurs-in-the-mfa-registration-page&ref=bulkifiedthinking.com) When new users log in to Salesforce for the first time and are asked to set up MFA, the setup fails with an “Insufficient Privileges” error. The workaround is to ask the user to log out, log back in and try the MFA setup again (if it’s shown). That should fix it. Make sure to talk to your users and check your production org before the busy week starts eating you alive. Stay safe, Super Intelligence is here! ### How Agentforce Finds Answers in Unstructured Data: Vector Databases & RAG URL: https://www.bulkifiedthinking.com/how-agentforce-finds-answers-in-unstructured-data-vector-databases-rag/ Last updated: 2026-09-30T03:02:53.000Z In the last couple of months, I keep hearing more about Agentforce being able to understand your organization, pick the relevant pieces of information and work with different chunking strategies. The less obvious part is that most of it eventually accumulates into one simple question Agentforce has to answer: How do I find the **relevant** information? You, all technical engineers out there, can criticize me and say that finding information is a matter of a simple keyword search, as long as the data is properly indexed. In reality, it comes down to many other problems: millions of records from different data sources, users using completely different wording, LLM token limits, accuracy getting worse when useful information is buried under piles of useless context, and, of course, unstructured data. Unstructured data is an incredibly interesting one. How can Agentforce (or your custom application) discover a few relevant pieces of information across thousands of files, support tickets, emails and conversations circulating around your organization? You might suggest finding a file by its title, description or summary using SOQL or SOSL, passing the entire thing to an LLM and hoping it finds the right answer somewhere across dozens of pages. Which is, frankly, a terrible idea. No worries, though. That's why we're here. I want to look at what actually happens behind the unstructured scenes and why vector databases, search indexes and RAG make a difference. ## How To Make Unstructured Data Searchable? Unstructured data is information that does not follow a predefined schema in the same way your standard or custom records do. It can be PDFs, Word documents, images, emails, transcripts or support tickets. All of them can contain crucial information for your business. Yet, once you have enough of them, most of that information becomes a dead weight unless you have a good way to find it. Agentforce has the exact same problem. It is not an endless box capable of loading every document in your company, reading everything and figuring out what matters for every request. It needs a reasonable way to retrieve only the information that applies to what the user is asking. For reference, you can absolutely store the content in a regular relational database: ``` id | 001500000001AbcAAY name | contract.pdf content | "Customer may terminate..." ``` However, in this case, the problem is search. Imagine you have thousands of contracts and somebody asks: ```text Can I cancel my contract if the system has been unavailable for two days? ``` The actual contract might say: ```text Customer may terminate the agreement if the service remains unavailable for more than 48 consecutive hours. ``` You might notice a few things right away: - Your user said "cancel". The document says "terminate". - Your user said "system". The document says "service". - Your user said "two days". The document says "48 consecutive hours". A regular keyword search will struggle here because the words are different even though the meaning is identical. That's where [vectors](https://www.ibm.com/think/topics/vector-embedding?ref=bulkifiedthinking.com) come into play. A vector is a long list of numbers representing the meaning and context of a piece of information. Something like this: ``` [0.021, -0.813, 0.441, 0.092, ...] ``` These numbers mean absolutely nothing to us. Mathematically, though, they represent where that particular text sits in a large semantic space, or in simple terms, what does that particular text is all about. 💡 The original content still matters because this is what we eventually want the LLM to read. The vector is primarily a representation used to find that content. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/09/image-1.png) [What Is a Vector Database? An Illustrative Guide](https://www.altexsoft.com/blog/vector-database/?ref=bulkifiedthinking.com) Now, who creates those numbers? This is done by an embedding model. An embedding model is a model specifically designed to convert information into vectors. It is not the same generative LLM that eventually answers your question. Salesforce Data 360, for example, currently supports dedicated embedding models such as E5 Large V2, Multilingual E5 Large and SFR V2 Small for search indexes. So our contract text goes through an embedding model: ```text "Customer may terminate the agreement if the service remains unavailable for more than 48 consecutive hours." ↓ Embedding model ↓ [0.021, -0.813, 0.441, 0.092, ...] ``` Then our user asks: ```text "Can I cancel my contract if the system has been unavailable for two days?" ``` The same type of embedding model converts the question into another vector. ```text User question ↓ Embedding model ↓ [0.019, -0.791, 0.419, 0.103, ...] ``` If the vectors are mathematically close to each other, there is a good chance the meaning is close as well. The search index can therefore find the contract paragraph even though the user did not use the words "terminate", "service" or "48 consecutive hours". Basically, the search engine is using mathematics to find vectors located close to the vector representing the user's question. Those search engines normally use nearest neighbor search techniques and specialized indexes rather than comparing every vector against every other vector one by one. ## What About Large Documents? There is another problem. Converting a 150 page contract into a single vector would not be particularly useful. The contract can talk about salary, termination, confidentiality, benefits, intellectual property and another hundred things. Representing all of it as one semantic point would make our search very imprecise. That's why unstructured content is normally divided into chunks first. ```text Chunk 1: "This agreement begins January 1..." ↓ [0.291, -0.104, ...] Chunk 2: "Customer may terminate the agreement if the service remains unavailable for more than 48 consecutive hours." ↓ [0.021, -0.813, ...] Chunk 3: "Payments are due within 30 days..." ↓ [1.821, -0.011, ...] ``` Now when somebody asks about terminating the contract, we do not retrieve the entire contract. Instead, we retrieve Chunk 2. That makes the information cheaper to process, easier to fit into the model context and, most importantly, removes a lot of information that has absolutely nothing to do with the question. Salesforce does exactly this when building Data 360 search indexes. The content gets chunked and vectorized before it becomes available for semantic retrieval. Chunking, however, can introduce its own problems. Imagine a financial report containing a table like this: ```text Product Revenue Growth AI Platform $42.1M 87.9% Cloud $81.2M 21.4% ``` Now imagine the chunking algorithm splits it like this: ```text Chunk 1: Product Revenue Growth AI Platform $42.1M 87.9% Chunk 2: Cloud $81.2M 21.4% ``` The second chunk no longer contains the column names. Therefore, from the LLMs perspective: What does $81.2M mean? The same problem can happen when a conversation gets split away from the speaker name, when a section loses its title or when related paragraphs end up in completely separate chunks. At that point, the problem is no longer the LLM. You already broke the information before the LLM ever saw it. This is why Salesforce introduced [Data 360 Code Extension](https://developer.salesforce.com/docs/data/data-cloud-code-ext/guide/use-custom-code.html?ref=bulkifiedthinking.com) functions for custom chunking. They allow you to use Python to control how your content gets divided before vectorization and indexing. Sometimes you need chunks based on specific criteria. Sometimes you need more context. Sometimes you need to preserve table headers. Sometimes you want to remove PII before information gets indexed. Code Extension functions give you a place to introduce that logic. You can read more about it [here](https://developer.salesforce.com/blogs/2026/09/pro-code-chunking-of-data-360-search-indexes-for-agentforce?ref=bulkifiedthinking.com). And this is one of those areas where the implementation detail can have a very real business impact. You can use the smartest LLM available and still get a terrible answer if the right information was never retrieved in the first place. ## How Does RAG Fit in Here? We now understand vectors, chunks, search indexes and how relevant information is extracted. But what the hell is RAG and how does it fit in here? [Retrieval-Augmented Generation](https://aws.amazon.com/what-is/retrieval-augmented-generation/?ref=bulkifiedthinking.com) (RAG) is basically the process of finding relevant external information first and then giving that information to the LLM before asking it to generate the answer. Imagine a customer asks: ```text Our service has been unavailable for two days. Can we terminate the contract? ``` Agentforce might already know some structured Salesforce information: ``` Account Contact Product Support Plan Current Cases ``` But the answer could depend on information stored elsewhere: ``` Signed contract Service terms SLA Internal support policy Previous communication ``` Those files contain organization specific knowledge the model was never trained on. Without RAG, you have two bad options: - You give the LLM none of that information and risk it making assumptions. - You send every contract, every support document and every policy to the model and ask it to figure everything out. The second option is, frankly, ridiculous. LLMs cannot reasonably process terabytes of your organization's knowledge every time somebody asks one question. Even when something technically fits into a large context window, filling that context with mostly irrelevant information does not magically make the response better. That's why we need RAG. ```text USER QUESTION "Our service has been unavailable for two days. Can we terminate the contract?" ↓ Retrieve structured customer context Account Product Support Plan Cases + Retrieve relevant company knowledge Contract Chunk 18 SLA Chunk 7 Support Policy Chunk 31 ↓ Build the context for the LLM ↓ Generate the answer ``` At this point, it's important to understand that the LLM does not receive the vectors. It receives the actual text retrieved with the help of those vectors. The vectors have already done their job, they helped us find the right contract section. The LLM can now work with human readable information. RAG also does not mean vector search only. Sometimes an exact keyword is much more useful. Imagine somebody searches for: ``` INC 00048192 ``` There is not much semantic meaning to understand there. You probably want an exact match. This is why Data 360 also supports hybrid search, which combines vector search with keyword search. Salesforce specifically positions hybrid search for situations where semantic meaning and exact terms both matter. An organization can therefore have many different retrieval mechanisms working together. [RAG](https://aws.amazon.com/what-is/retrieval-augmented-generation/?ref=bulkifiedthinking.com) is the broader process that takes the relevant pieces and uses them to ground the model. ## Putting the Agentforce Flow Together Let's finally put everything into one picture. Assume your organization has Salesforce records, PDFs, Knowledge articles, emails, support conversations and some documents coming from an external system. The flow will look like this: ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/09/agentforce-final-diagram-1.png) There are a few different responsibilities hiding inside this diagram. - **Organization Data** is the actual information your business works with. Salesforce records, PDFs, Knowledge articles, emails, support conversations and anything else you connect to Data 360. - **Data 360** prepares that information for search. For unstructured content, this includes parsing the file, splitting it into chunks and creating vector embeddings for those chunks. - The **Search Index** stores the searchable representation of that information. Depending on the configuration, it can use vector search, keyword search or a combination of both. - The **Retriever** uses that Search Index when Agentforce needs information. It takes the current request, searches the index and returns the most relevant chunks. You can also configure filters and other retrieval rules to control what comes back. - The retrieved chunks are then combined with the **current user request** and any **structured business context** Agentforce already has, such as Account, Case or Product information. - **RAG** is the process of taking all of that retrieved context and adding it to the prompt before the model is called. - The **LLM** receives the prompt together with that context and generates the final answer. I personally love this kind of stuff, when we look beyond the platform and explore what’s actually driving it all behind the scenes. What do you think? ### Did Salesforce’s Massive Outage Work as Marketing for AIforce? URL: https://www.bulkifiedthinking.com/posts/did-salesforces-massive-outage-work-as-marketing-for-aiforce/ Last updated: 2026-09-18T00:45:27.000Z September 16, 2026, was expected to be a successful second day of Dreamforce, uncovering more headless capabilities for our AI Agents to communicate with Salesforce and use its capabilities without needing to log in to Salesforce directly. Instead, it all started with one of Salesforce's biggest outages, gifting customers across all regions with severe delays and the inability to access Salesforce at all. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/09/image.png) [Trust Status - Salesforce](https://status.salesforce.com/?ref=bulkifiedthinking.com) Indeed. Considering all the announcements, why would you even need to log in to Salesforce? It's a headless era and September proved it to be quite headless. During the incident, Salesforce reported that requests were stalling while waiting for a response from an internal login service, consuming available server resources. Shortly after, Salesforce said it believed an external dependency failure was affecting the legacy login server. A core system component then experienced increased load and could no longer process requests at the required capacity. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/09/salesforce-outage-september-16-2026.png) This incident occurred literally a few hours after the announcement of [AIforce](https://www.bulkifiedthinking.com/dreamforce-2026-aiforce-koa-and-the-technical-story-so-far/), a product name bringing Salesforce's headless capabilities under a single product direction and promoting even more headless interaction with Salesforce. Quite timely, I must say. All things aside though, this incident proves that underneath all that AI revolving machine of progress and probabilistic thinking, we still have good old technologies and dependencies which, at the end of the day, can make a bigger impact than any AI capability introduced into the ecosystem. Not to mention that I have a feeling AI coding had its place somewhere in this incident, but let's stay away from speculation. Instead, I highly encourage all of you to have a **mitigation plan** for Salesforce outages. Have a checklist to notify users about an outage in a timely manner, explain how they should proceed without Salesforce and determine how you will push back any data that may have been drafted elsewhere during those hours of downtime. Think about what a Salesforce outage means for your business and how you will mitigate the impact. If you have none - you have no idea what you're doing. And this is not a unique outage. Over the last few weeks, Revenue Cloud alone has suffered several significant problems. They obviously don't receive the same level of attention, but they prove exactly the same point: there are still plenty of dependencies and moving parts behind the scenes and we need to be prepared when one of them fails. ### Dreamforce 2026 | AIforce, Koa and the Technical Story So Far URL: https://www.bulkifiedthinking.com/dreamforce-2026-aiforce-koa-and-the-technical-story-so-far/ Last updated: 2026-09-17T12:25:15.000Z It's been two days of Dreamforce 2026 and I think it's time to talk about what's actually changing for architects, developers and enthusiasts in the Salesforce ecosystem. As always, Dreamforce is packed with marketing statements and bold narratives, which is why we'll focus strictly on the technical side of those announcements. ## [AIforce](https://www.salesforce.com/news/stories/aiforce-announcement/?ref=bulkifiedthinking.com) Salesforce introduced [**AIforce**](https://www.salesforce.com/news/stories/aiforce-announcement/?ref=bulkifiedthinking.com), describing it as a new interface layer on top of Customer 360, Data 360 and Agentforce. AIforce is primarily a product layer (keep this in mind) around the headless capabilities Salesforce has already been building, including: Headless 360, Hosted MCP servers, APIs, skills and plugins. You have already been using Salesforce in Claude, Copilot and other AI Agents for a long time now, therefore from a technical standpoint - nothing has changed. If you were asleep for the last part of the year though, allow me to remind you what [Headless 360](https://www.salesforce.com/headless/?ref=bulkifiedthinking.com) is. Headless 360 wasn't introduced at Dreamforce. Its Hosted MCP Server entered beta earlier this year and Salesforce expanded its headless capabilities again in August. The idea is that Salesforce itself provides an MCP layer for discovering and executing Salesforce capabilities instead of making every developer build that layer independently. Without it, your implementation could look like this: ```text Claude ↓ Your MCP Server ↓ OAuth ↓ Salesforce APIs ↓ SOQL / Apex / Flow ↓ if-else conditions ↓ Result ``` There is nothing wrong with this architecture. In fact, this is how many of us have already been connecting AI Agents to Salesforce. The problem is **ownership**. You or your AI Agent has to define the tools, understand the APIs, maintain the contracts and decide which Salesforce endpoint should be called. And when Salesforce changes or extends those capabilities, maintaining the integration remains **your** responsibility. Headless 360 moves part of that responsibility back to Salesforce. The Headless 360 MCP Server exposes four tools: ```text discover describe dispatch dispatch_readonly ``` Behind them, Salesforce maintains an expanding set of operations and skills. Say I ask Claude: > Find Experience Cloud users linked to Contacts with no Title who haven't logged in for 30 days. Claude does not need a separate tool definition for every Salesforce operation it might potentially use. It can first ask Salesforce what is available: ```text Prompt ↓ discover( "find Experience Cloud users with missing Contact Title and no recent login" ) ↓ Relevant Salesforce operations ``` `discover` performs a semantic search across the operations available through Headless 360 and returns the most relevant ones. Once Claude decides which operation it needs, it can request its specification: ```text describe(selected_operation) ↓ Parameters Dependencies Required APIs Execution steps ``` Then it can execute it: ```text dispatch(...) ``` or, if the operation only reads information: ```text dispatch_readonly(...) ``` That is the actual technical value of Headless 360\. Salesforce can add another hundred supported operations behind Headless 360 while the MCP interface exposed to Claude remains the same. ## [Koa](https://www.salesforce.com/agentforce/koa/?ref=bulkifiedthinking.com) Salesforce and NVIDIA introduced [**Koa**](https://www.salesforce.com/agentforce/koa/?ref=bulkifiedthinking.com), Salesforce's first CRM reasoning model for Agentforce. Unlike AIforce, there is actually a new model behind this announcement. Koa is based on **NVIDIA Nemotron 3 Super** and Salesforce has post trained it specifically for enterprise and CRM workflows. Practically speaking, imagine the following scenario: A customer receives a damaged product and asks for a refund. Koa could be given tools such as: ```text Get Order Check Refund Policy Create Refund Create Replacement Escalate Case ``` The model needs to determine which tools to use and in which order. ```text Customer Request ↓ Get Order ↓ Check Refund Policy ↓ Choose Next Action ↓ Refund / Replace / Escalate ``` Salesforce then evaluates whether the model actually completed the task correctly. The process is repeated across synthetic scenarios covering different workflows, personas and industries. Salesforce says the training environments cover more than 14 industries. The purpose is not to make Koa know your Salesforce org. - It doesn't know your business. - It doesn't know your custom objects. - It doesn't know your rules. That information still has to be provided at runtime. What Koa is trained to understand better is the pattern of enterprise work itself, particularly workflows that require multiple steps, multiple tools and decisions between them. I suppose we'll see how things turn out in the near future. ## Koa Does NOT Replace Atlas Salesforce has the [**Atlas Reasoning Engine**](https://www.salesforce.com/agentforce/what-is-a-reasoning-engine/atlas/?ref=bulkifiedthinking.com) and now it also has a **Koa Reasoning Model**. They are not the same thing and Atlas **remains** responsible for executing the Agentforce process. Koa is one model that can be called when that process requires probabilistic reasoning. ```text Agentforce ↓ Atlas ↓ Agent Graph ↓ Does this step require an LLM? ↓ Koa / another supported model ``` If an Agent already knows: ```text Amount > $1,000,000 ↓ Require Approval ``` there is no reason to ask Koa whether one million is greater than one million. ## Missionforce + OpenAI Another announcement worth mentioning came from Missionforce. Salesforce announced an expanded OpenAI partnership for government customers where authorized users can work with Missionforce capabilities through ChatGPT. Technically, this follows the same headless model we've already discussed: ChatGPT becomes another client interacting with Salesforce capabilities. The more interesting part is the [**Missionforce Policy Engine**](https://www.salesforce.com/news/stories/missionsforce-expansion-agents-nvidia-openai-partnerships/?ref=bulkifiedthinking.com). The goal of this new engine is to take an approved policy document and use an OpenAI model to convert it into structured rules and corresponding test cases. This creates a separation: ```text Policy Document ↓ LLM interprets it ↓ Rule Code + Tests ↓ Human Review ↓ Deterministic Execution ``` In this case, once the policy has been converted and approved, the actual decision can be executed through deterministic rules instead of asking the model to reinterpret the same document again. As someone who has been working with file management systems for quite some time, I've thought about building something of the kind myself and it's nice to see I'm not the only one. ## Google & AWS Salesforce also used Dreamforce to expand its partnerships with Google Cloud and AWS. I want to emphasise “expand” because these partnerships had already been established before Dreamforce. For Google Cloud, Salesforce announced that Gemini Enterprise can use Salesforce's headless architecture to work with Salesforce data and actions without customers building the entire integration themselves. Salesforce also confirmed that Hyperforce is already running production workloads on Google Cloud with North American general availability planned for November 2026. AWS follows a similar direction. Salesforce is expanding model choice through [Amazon Bedrock](https://aws.amazon.com/bedrock/?ref=bulkifiedthinking.com) and exposing Salesforce context and capabilities into AWS products. These announcements confirm that Salesforce is standardizing the same pattern across more AI platforms: ```text AI Client ↓ Standardized Salesforce Access ↓ Salesforce Data + Logic + Actions ``` We can still build custom integrations where they make sense. The difference is that we increasingly won't need to build the basic connection ourselves every. single. time. ## Two Days In If you're looking for my take so far, the direction is pretty obvious: we're moving towards exposing more and more Salesforce capabilities to different interfaces, such as Claude Code and ChatGPT, allowing them to work with Salesforce data and workflows without leaving the Agent window. This is good news and there is nothing wrong with moving in this direction. At the same time, I encourage you to remember that most of your users will still be using the Salesforce interface just as intensively as they were before. These integrations can consolidate access in some scenarios. In all the remaining ones, please go ahead and improve your user experience. ### My Name Is Apex Google Drive Library. I Allow You to Manage Google Drive Directly from Salesforce URL: https://www.bulkifiedthinking.com/my-name-is-apex-google-drive-library-i-allow-you-to-manage-google-drive-directly-from-salesforce/ Last updated: 2026-09-01T16:31:29.000Z I want to be honest with you. There are many AI slop open source projects right now that were vibe coded over a weekend and made their way into your organizations while bringing bugs, vulnerabilities and performance problems along with them. I would love to be one of those... But **I cannot**. My name is [Google Drive Library for Apex](https://github.com/sandriiy/salesforce-google-drive-library?ref=bulkifiedthinking.com). I am an open source SDK that provides you with a simplified way to work with Google Drive API directly from Apex without leaving Salesforce at all. You tell me what you want to do and I translate it into something Google Drive understands. And with the latest release I can do much more than before. My maintainer treats me well. He feeds me, updates me and verifies changes against real enterprise and personal Google accounts for which he pays from his own pocket. You can find me here: [https://github.com/sandriiy/salesforce-google-drive-library](https://github.com/sandriiy/salesforce-google-drive-library?ref=bulkifiedthinking.com) You can find release notes here: [https://github.com/sandriiy/salesforce-google-drive-library/releases/tag/v1.3.0](https://github.com/sandriiy/salesforce-google-drive-library/releases/tag/v1.3.0?ref=bulkifiedthinking.com) ## Apex Symbol API With Winter ’27 Salesforce is opening up much more information about the Apex code available in your organization to development tools and AI Agents. The [Apex Symbol API](https://help.salesforce.com/s/articleView?id=release-notes.rn%5Fapex%5Fsymbol%5Fapi.htm&release=264&type=5&ref=bulkifiedthinking.com) allows those tools to retrieve structured information about Apex types such as classes, interfaces, methods, parameters, return types, modifiers, annotations and documentation. Which is why I now have better method names, clearer signatures and ApexDoc all over the place. ```apex /** * Provides access to file revision operations. * * @return Revision factory associated with this Google Drive instance. */ public GoogleRevisionFileFactory revisions() { return new GoogleRevisionFileFactory(this); } ``` This means you can spend less time explaining your dependencies and more time asking the Agent to actually solve the problem. ## File Versions and Revision History One of the most familiar capabilities in Google Drive is the ability to keep previous versions of a file. You change something and Google Drive keeps the previous content so that you can see what existed before. This is particularly useful when Salesforce is responsible for generating or managing documents such as contracts, invoices, reports, statements or anything else that can change during its lifetime. Which is why, you can simply upload new content into the existing file. ```apex GoogleFileEntity newVersion = remoteGoogleDrive.files() .modify() .simpleUpdate(remoteFileId) .setContentType('text/plain') .setBody(Blob.valueOf('The new content of the file')) .setKeepRevisionForever(true) .setSupportsAllDrives(true) .execute(); ``` The library also supports `multipartUpdate()` and `resumableUpdate()` depending on how the new content needs to be uploaded. The important part is that the existing Google Drive file remains the same. Google Drive keeps the previous content as another revision and you can now work with those revisions directly from Apex. For example, you can retrieve the history of a file: ```apex GoogleRevisionSearchResult result = remoteGoogleDrive.revisions() .search(fileId) .setMaxResult(100) .setFields('revisions(id,modifiedTime,size,keepForever,lastModifyingUser),nextPageToken') .execute(); ``` This allows Salesforce to see previous revisions and information such as when they were created, their size, whether they should be retained permanently and where available which user last modified them. From a business perspective this opens a few interesting opportunities. - You can show previous versions of a document directly inside Salesforce. - You can preserve important revisions for compliance purposes. - You can keep track of who changed the content. - You can recover from somebody uploading something they should not have uploaded. To restore an older binary revision you retrieve its content and upload that content again as the newest version. ```apex GoogleRevisionEntity previous = remoteGoogleDrive.revisions() .retrieve(fileId, revisionId) .setRevisionDownloadType( GoogleRetrieveRevisionBuilder.DownloadType.CONTENT ) .execute(); GoogleFileEntity restored = remoteGoogleDrive.files() .modify() .simpleUpdate(fileId) .setContentType('text/plain') .setBody(previous.bodyAsBlob) .setSupportsAllDrives(true) .execute(); ``` The file keeps the same ID and the previous content becomes current again. You can also pin important binary revisions using `keepForever`, modify supported revision properties and remove revisions when required. ## File Labels In highly regulated industries one of the important questions around file storage is not only where a file is stored but what that file actually represents. Maybe it contains confidential information? Maybe it belongs to a particular department? Maybe there are retention requirements associated with it? Google Drive labels provide structured metadata which can be attached to files to classify them. Your Google Workspace administrators define those labels and their fields and then users or integrations apply them to actual files. And now Salesforce can do that directly as well. First, you can retrieve the labels already applied to a file: ```apex GoogleLabelSearchResult result = remoteGoogleDrive.labels() .search(fileId) .setMaxResult(100) .execute(); ``` This allows Salesforce to understand how the file has already been classified. You can also apply or update a label: ```apex GoogleModifyLabelsResult result = remoteGoogleDrive.labels() .modify(fileId) .addLabelModification( new GoogleLabelModificationBuilder() .setLabelId('SEND70dQN5lH71BeU4nrBgrBit6Pvst1') .setTextValues( '6AC8AD1AF5', new List{ 'Confidential' } ) ) .execute(); ``` Text, selection, date, integer and user fields are supported. ## Download Large Files Salesforce is increasing Apex heap limits in Winter ’27\. Synchronous transactions are moving from 6 MB to 10 MB and asynchronous transactions from 12 MB to 25 MB. Which is awesome. But if I can avoid putting a 500 MB file into Apex completely, I would still prefer that. The library already had approaches for dealing with large files but this release introduces another option which is particularly useful when the final destination of the file is the user's browser. Instead of downloading the file into Apex you can retrieve a download URL. ```apex GoogleOperationEntity operation = remoteGoogleDrive.files() .retrieve() .downloadLink(fileId) .execute(); if (operation.done && operation.error == null) { String downloadUrl = operation.response.downloadUri; } ``` You can return that URL to your Lightning Web Component and let the browser download the file directly. ```javascript const downloadUrl = await getDownloadUrl({ fileId: this.fileId }); window.location.assign(downloadUrl); ``` In most cases the operation completes immediately and the URL is available in the first response. If it is still processing you can retrieve the operation again later: ```apex GoogleOperationEntity operation = remoteGoogleDrive.operations() .retrieve(operationName) .execute(); ``` In terms of security: Your OAuth token, service account credentials and certificates remain on the server side. The Lightning Web Component only receives the download URI returned for the requested file. ## Scoped Authorization Throughout most of my life I have given you one interface called `GoogleAuthorizer`. It contains one method: ```apex String retrieveAccessToken(); ``` You implement authentication however you want and return me an access token. This has always been **intentional**. You should not need to give an open source library your private keys, secrets or any other authentication details just so that it can call Google Drive. Just give me an access token and I will do the rest. There was one limitation with the original interface though. When your implementation received a request for an access token it had no information about which Google OAuth scopes that credential needed. The authorizer saw exactly the same method call. And when you do not know which level of access will be required the easiest option is usually to request a broader scope. This is where `GoogleScopedAuthorizer` comes in. When you create your Google credential you can now specify the scopes you want to request. ```apex GoogleCredential googleDriveCredentials = new GoogleAuthorizationCodeFlow.Builder() .setLocalGoogleAuthorizer('CustomScopedAuthorizer') .setRequestedScopes( new List{ GoogleScope.DRIVE_FILE } ) .build(); ``` Your custom authorizer receives those scopes: ```apex public with sharing class CustomScopedAuthorizer implements GoogleScopedAuthorizer { private final String SERVICE_ACCOUNT_EMAIL = 'YOUR_SERVICE_ACC_EMAIL'; private final String CERTIFICATE_NAME = 'YOUR_CERTIFICATE_NAME'; public String retrieveAccessToken(List requestedScopes) { Auth.JWT jwt = new Auth.JWT(); jwt.setAud('https://oauth2.googleapis.com/token'); jwt.setSub(SERVICE_ACCOUNT_EMAIL); jwt.setIss(SERVICE_ACCOUNT_EMAIL); jwt.setAdditionalClaims( new Map{ 'scope' => GoogleScopeResolver.toScopeUrls(requestedScopes) } ); Auth.JWS jws = new Auth.JWS(jwt, CERTIFICATE_NAME); Auth.JWTBearerTokenExchange bearer = new Auth.JWTBearerTokenExchange(jwt.getAud(), jws); return bearer.getAccessToken(); } } ``` Now your authorization layer knows what was requested and can create an access token with the appropriate scopes. ## Shared Drives and Permissions There is also much more control over Shared Drives in this release. Previously you could primarily use the library to search and work with files stored inside them. Now you can manage the Shared Drives themselves. ```apex remoteGoogleDrive.drives().create(); remoteGoogleDrive.drives().retrieve(); remoteGoogleDrive.drives().modify(); remoteGoogleDrive.drives().remove(); remoteGoogleDrive.drives().hide(); remoteGoogleDrive.drives().unhide(); remoteGoogleDrive.drives().search(); ``` This becomes useful when Salesforce is responsible not only for putting files into Google Drive but also for creating the storage structure behind a business process. ## Other Capabilities At this point I have already grown quite a bit and there are also several smaller improvements included in the release. - Calling `setDriveId(...)` now automatically sets `corpora=drive` as required by Google Drive when searching within a particular Shared Drive. - PATCH requests no longer manually set the content length header which Salesforce does not allow in this context. - Test coverage has also been expanded across the new functionality. And the library now exposes all of its major areas through the same `GoogleDrive` instance: ```apex googleDrive.files(); // Uploads, downloads, modification, cloning, deletion, trash management and search googleDrive.drives(); // Shared Drive management googleDrive.permissions(); // Access control googleDrive.revisions(); // File history googleDrive.labels(); // Classification googleDrive.operations(); // Access to Google long running operations ``` The amount of functionality keeps growing but the idea stays the same. I am here so that you do not have to write Google Drive API integrations from scratch every time your Salesforce project needs one. ## Feedback My maintainer is a good man. At least this is what he tells me. He believes feedback is what moves progress forward and I agree with him. - If something is missing, create a [GitHub issue](https://github.com/sandriiy/salesforce-google-drive-library/issues?ref=bulkifiedthinking.com). - If you have an idea, start a [discussion](https://github.com/sandriiy/salesforce-google-drive-library/discussions?ref=bulkifiedthinking.com). - If something does not work, tell [us](https://www.linkedin.com/in/andrii-sukhetskyi-305939201/?ref=bulkifiedthinking.com). The Salesforce ecosystem has always been driven by people sharing their problems, ideas and solutions with each other. I am open source. So if there is something you want me to become, you have a chance to become part of what comes next. ### SOAP API login() Is Breaking in Winter ’27 | I Was Right URL: https://www.bulkifiedthinking.com/posts/soap-api-login-is-breaking-in-winter27/ Last updated: 2026-08-31T23:46:23.000Z A few days ago, I published [my theory](https://www.bulkifiedthinking.com/posts/soap-api-login-retirement-i-think-everyone-has-the-wrong-date/) that the retirement of the SOAP API `login()` method would start affecting existing integrations in the upcoming Winter ’27 release, not just Summer ’27 as most people were assuming. You called me crazy. You said nobody was telling us that. Well... Today, I confirmed it. Existing integrations using SOAP API `login()` can no longer authenticate in Winter ’27 unless the user has a new permission called **Use Any API Auth**. You can find it in a permission set by navigating to: **Permission Sets → System Permissions → Use Any API Auth** ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-12.png) Any user trying to authenticate using SOAP API `login()` without this permission will be rejected with the following error: `INSUFFICIENT_ACCESS: SOAP API login() requires the Use Any API Auth user permission` ## How to Find Who Is Still Using SOAP API login() Before assigning the permission to anyone, you first need to identify which integrations are still using `login()`. Go to: **Setup → Login History** You can review the records there or download the login history as a CSV file. Look for entries where: - **Login Type** is `Other Apex API` or `Partner Product` - **Login Subtype** is `SOAP API` - **API Type** is `SOAP Enterprise`, `SOAP Partner` or `SOAP Tooling` Those entries represent applications authenticating through SOAP API `login()`. The **Username** tells you which Salesforce user is being used by the integration. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-13.png) And don't be surprised if the **Application** field says `N/A`. That's expected. SOAP API `login()` isn't tied to a Connected App or External Client App, which is kind of the whole problem here. If you need to investigate further, you can use the **API Total Usage EventLogFile**. Look for events where: - `API_FAMILY = SOAP` - `API_RESOURCE = login` The `USER_NAME` identifies the user, while `CLIENT_NAME` can help identify the actual application. ## What You Should Do Before Winter ’27 Review your Login History and identify every user still authenticating through SOAP API `login()`. If you can't migrate those integrations just yet, create a dedicated permission set containing **Use Any API Auth** and assign it only to the users that actually need it. That will keep those integrations alive through Winter ’27. But don't get too comfortable with it. In Summer ’27, SOAP API `login()` in API versions 31.0 through 64.0 will be retired once and for all. At that point, there will be no permission you can assign and no checkbox you can enable to keep it alive a little longer. ### SOAP API login() Retirement: I Think Everyone Has the Wrong Date URL: https://www.bulkifiedthinking.com/posts/soap-api-login-retirement-i-think-everyone-has-the-wrong-date/ Last updated: 2026-08-27T03:40:25.000Z As you might know, Salesforce is retiring the SOAP API `login()` method that **external systems** use to authenticate into your Salesforce organization. It's a legacy method that some of your systems might still be using to get into Salesforce, and considering it's an extremely dumb and insecure way of doing it (just a plain username and password), it's indeed a good decision to get rid of it. This enforcement is scheduled for **Summer '27** and will make API versions **31.0 through 64.0 unavailable**, returning an error saying: ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-9.png) The Lord of the Rings | You Shall Not Pass And before you scroll away, **STOP**. Maybe your organization **will stop working already after Winter '27**. The existing Salesforce documentation, as of the second I'm writing this post, is extremely confusing (as always) and might give us a hint that starting Winter '27, your users **must** have a specific permission to keep the SOAP API `login()` method working until Summer '27. *YES, I KNOW, PLEASE BEAR WITH ME!* ### **Winter '26 Release Notes** *This is an older release, from last year.* Back in a day, Salesforce publishes a release note saying that all versions starting from 65.0 won't have the SOAP API `login()` method working at all. Reference: [SOAP API | Salesforce Help](https://help.salesforce.com/s/articleView?id=release-notes.rn%5Fapi%5Fsoap.htm&release=258&type=5&ref=bulkifiedthinking.com) So far it makes sense. ### Summer '26 Release Notes The release right before Winter '27\. This summer. Salesforce introduced a new checkbox under User Interface named "**Enable SOAP API login() to users with the Use Any API Auth user permission**". ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-11.png) Setup | User Interface Activate it, and the SOAP API `login()` method stops working for all users EXCEPT those who have the **Use Any API Auth** permission. That permission is also a new one, introduced in this very release. Reference: [Restrict Login Access to SOAP API | Salesforce Help](https://help.salesforce.com/s/articleView?id=release-notes.rn%5Fapi%5Fsoap%5Flogin.htm&release=262&type=5&ref=bulkifiedthinking.com) They allow us to do a round of testing. ### Winter '27 Release Notes And then in the upcoming Winter '27 release notes, I quote: "To authenticate with the SOAP API login() operation, all users **must** have the Use Any API Auth user permission assigned to them". If my interpretation is correct, **it means that all your SOAP API `login()` integrations will break not in Summer '27 as you all assume, but literally in a few weeks with Winter '27, unless you assign that permission.** You might say I am overreacting, but why would you then publish this release page in the first place? Like, the checkbox is there, the permission is there, there is nothing new unless you now enforce having this permission attached. Just look at the reference. Reference: [Assign Use Any API Auth Permission for SOAP login()](https://help.salesforce.com/s/articleView?id=release-notes.rn%5Fapi%5Fsoap%5Flogin.htm&release=264&type=5&ref=bulkifiedthinking.com) ### My detective conclusion Winter '27 will break your SOAP API `login()` integrations and will give you a way to repair them quickly. The problem is, if it weren't for me, you would still be in for the downtime. On that note, I recommend looking into this already today. To identify all SOAP API usage, you shall: - Look into your inbox. Salesforce must have sent you an email a couple of months ago saying that they identified that your org has been accessed using SOAP API `login()`. - Go to Login History and look for "Other Apex API", "Partner Product" or "SOAP API". If any of these are present, you can track back the user and then see which integration is using this older authentication method. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-10.png) Setup | Login History - Finally, verify that all your AppExchange packages are updated and not using those older authentication methods. This is really easy to overlook, as you usually don't think of it. To help you communicate with other teams, share with them that their code most likely looks something like this: ```Java ConnectorConfig config = new ConnectorConfig(); config.setUsername("integration@example.com"); config.setPassword("passwordSECURITY_TOKEN"); config.setAuthEndpoint("https://YourDomainName.my.salesforce.com/services/Soap/c/41.0/"); ``` The idea behind such integrations is simple. If your external application is accessing Salesforce with a username and password only — that's bad. It's either SOAP API `login()` or OAuth username-password, both of which are on their way out as we speak. I'll keep you updated once we get Winter '27 in our sandboxes and whether my detective masterpiece is truthful. ### Salesforce Winter `27 Is Here: All Developers Need to Know URL: https://www.bulkifiedthinking.com/salesforce-winter-27-is-here-all-developers-need-to-know/ Last updated: 2026-08-25T03:36:12.000Z While we’re still licking our wounds from Summer ’26 and the various security updates that ran us over several times this summer, it’s time to talk about the next stage of our suffering. The Winter ’27 release is around the corner, and today we’re going to talk about the **good** things in this release that will give all developers a little dose of ecstasy. [Create a Preview Org](https://developer.salesforce.com/signup?ref=bulkifiedthinking.com) [Read the Release Notes](https://help.salesforce.com/s/articleView?id=release-notes.salesforce%5Frelease%5Fnotes.htm&type=5&ref=bulkifiedthinking.com) ## Apex One of the biggest surprises this time is the increase of the existing transaction **heap size** limit, which, just to be clear, does not happen very often. ### Increased Apex Heap Size Limits Now you are able to consume up to 10 MB (from 6 MB) of heap in a synchronous transaction, and up to 25 MB (from 12 MB) in asynchronous transactions. The exact numbers are 10,000,000 and 25,000,000 bytes. 0:00 /0:14 1× How to check heap size in Apex? What it means is that now in Apex you can store much larger datasets in a single transaction, which helps a lot of old features to be resurrected and new features to be used even more broadly, as they receive more room to operate within. It also helps with managing files in Salesforce. You can now naturally retrieve larger files and if you’re using external storage: partial upload or download becomes faster, because one chunk of a file is simply bigger than it used to be. And I haven’t even mentioned relatively new features like the [Compression namespace](https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex%5Fnamespace%5Fcompression.htm?ref=bulkifiedthinking.com), which was originally introduced to create and extract zip files, and which can now feel much more confident handling multiple files and compressing them into something that actually makes sense to your users. ```Apex // Synchronous System.debug(Limits.getLimitHeapSize()); // 10000000 System.debug(Limits.getLimitCpuTime()); // 10000 // Inside a Queueable, Future and Batch execute() System.debug(Limits.getLimitHeapSize()); // 25000000 System.debug(Limits.getLimitCpuTime()); // 60000 ``` Now, so you wouldn’t get too excited about this, my takes: - **The CPU time limit has not been changed.** It is still 10 seconds synchronous and 60 seconds asynchronous. You might be able to store large datasets and big media now, but that does **not** mean you will be able to process them. You’ll probably hit the CPU time just as good as you used to hit the heap size. Thank me for spoiling the party! - **I refuse to believe they were so worried about your and my struggling that they increased the heap size limit.** It’s definitely because of AI, it has already given us things like the [Models API](https://developer.salesforce.com/docs/ai/agentforce/guide/models-api.html?ref=bulkifiedthinking.com) to open a route for more interaction with LLMs and to give them more context from Apex, and I suspect there is much more coming in this direction, so Salesforce had to prepare itself for those changes by increasing the heap. Thank me for reminding you that your struggling makes no difference to the enterprise world! ### **Call HTTP Endpoints in Apex Tests (Developer Preview)** Another quite surprising upgrade goes to Apex test classes. In the new release, you’ll be able to create **true** integration tests and verify a real connection with third-party endpoints. > Unit testing validates small, isolated pieces of code, **while integration testing** verifies that components, services and external dependencies work together correctly. Before this release, the idea of working with a third-party endpoint was simple: you create a mock for it and try to predict what result it might return, how authentication works and every edge case related to the service. ```Apex @IsTest private class ExternalServiceCalloutTest { @IsTest private static void testHttpCallout() { // It's all fake and never triggers a real endpoint new HttpMock().get('/external/service').body('null').statusCodeBadRequest().mock(); Test.startTest(); HttpResponse result = ExternalServiceCalloutMock.getData(); Test.stopTest(); Assert.areEqual('null', result.getBody()); } } ``` In the Winter ’27 release, you are given an option to call a real HTTP endpoint right from your Apex test class, without creating any mock services **at all**. ```Apex // This works in Apex Tests Http protocol = new Http(); HttpRequest request = new HttpRequest(); request.setEndpoint('callout:NamedCredential/verify/' + acc.Id); request.setMethod('GET'); HttpResponse response = protocol.send(request); ``` It has slightly different syntax though, and requires a few annotations to be added to the class that behaves as an integration test. ```Apex @IntegrationTest public class ExternalEndpointTest { @IntegrationTest public static void testExternalEndpoint() { // ... } } ``` 💡 Existing Apex test classes are ****not** affected, because they use different syntax and serve a different purpose. If you want a little more interesting insights: **Nothing rolls back.** I ran a few scenarios inserting records in `@BeforeClass` and a few more inside the test method, and after the run every single one of them was still sitting in the org. Regular Apex tests roll back and you never think about it. Integration tests commit, and your org keeps whatever you inserted. Which is exactly why `@TearDown` is something you'd like to use: ```Apex @IntegrationTest public with sharing class ExternalEndpointTest { @BeforeClass public static void setup() { Account newCompany = new Account(Name = 'PaymentTestAccount'); insert as user newCompany; } @IntegrationTest public static void testExternalEndpoint() { Account acc = [ SELECT Id FROM Account WHERE Name = 'PaymentTestAccount' WITH USER_MODE ]; Http protocol = new Http(); HttpRequest request = new HttpRequest(); request.setEndpoint('callout:PaymentGateway/verify/' + acc.Id); request.setMethod('GET'); HttpResponse response = protocol.send(request); Assert.areEqual(200, response.getStatusCode()); } @TearDown public static void tearDown() { delete [SELECT Id FROM Account WHERE Name = 'PaymentTestAccount']; } } ``` For all the updates, retirements and more security updates, head over to the [Posts](https://www.bulkifiedthinking.com/posts/?important=true) tab to stay up to date on what could break your organization. [Learn more ](https://www.bulkifiedthinking.com/posts/?important=true) ### Additional Limits for Batch Apex (Beta) Your organization has a rolling 24-hour limit on how many asynchronous Apex jobs it can run. Once you cross it, new jobs are rejected and your code receives a limit exception. Elastic limits change what happens at that point. Instead of rejecting jobs the moment you reach the standard limit, the org keeps accepting them and reduces the processing rate instead. The idea itself is **not new**. Summer ’26 introduced elastic limits for Queueable jobs and future methods. What Winter ’27 adds is **Batch Apex**. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-7.png) Setup | Apex Settings You’ll find the switch in **Setup → Custom Code → Apex Settings → Use elastic limits for asynchronous Apex jobs (Beta)**. Hover over the help icon next to it and the org gives you your own numbers. With the setting off, it says: > The elastic limits setting is disabled. Your standard asynchronous job limit is **N** jobs executed per 24-hour period. And with the setting on, it says: > The elastic limits setting is enabled. Your elastic asynchronous job limit is **N+E** jobs executed per 24-hour period. Me personally, I have never hit those limits in my entire life. And if you do, most likely you’re doing something wrong with the architecture or implementation of your Salesforce organization. For those, you can read more about how to design an org that does not suck here: [Design Your Org The Right Way](https://www.bulkifiedthinking.com/salesforce-design-your-org-the-right-way/) ### Apex Symbol API (Beta) This one will primarily help your AI agents to understand Salesforce better. It gives you a new endpoint `/services/data/vXX.0/tooling/symbols`, which you or your agent can call to retrieve detailed information about the standard and custom Apex classes, interfaces and triggers that are available for use. ```HTTP GET /services/data/v68.0/tooling/symbols?category=builtin&namespace=System&name=ApexPages Authorization: Bearer ``` The `category` parameter is mandatory and takes `builtin`, `database` or `dynamic`. What comes back is everything the compiler knows about the type: every method, every parameter, every modifier and every annotation. It also reads your ApexDoc. The `@description` tag becomes the leading text, and every `@param` is pulled out a second time into the parameter it belongs to: ```JSON { "name": "snapshot", "returnType": { "name": "String" }, "documentation": "Builds a one-line snapshot of the current governor limits.\n@param ctx ...", "parameters": [ { "name": "ctx", "documentation": "A label identifying the execution context." } ] } ``` So you don't write ApexDoc, an agent reading this API has **nothing** to work with beyond your method names, and we all know how descriptive your methods are. I have no clear opinion on this capability yet. It needs time to show itself and whether it does bring a benefit. That said, the fact that Salesforce APIs are extending is definitely good news, and I’ll be posting updates on the [Posts](https://www.bulkifiedthinking.com/posts/?important=true) section of this newsletter to keep you informed. ### API Versions Retirement We also have a retirement coming for old Apex. If you’re using classes and triggers marked with **versions 9.0 through 19.0**, first of all, you suck. Second of all, you must upgrade those classes to a newer version to avoid any compilation errors moving forward. To help you with identifying them, Salesforce now returns a warning when you compile or deploy at those versions: ``` LegacyWarnProbe: 1,14 SEVERE [1:14]: Apex API version 16.0 is scheduled for retirement. Update to the latest API version to avoid compilation errors. ``` ## Salesforce CLI The command-line interface has no such exciting updates this time, and mostly has small changes to current behavior which will not affect you. Maybe 😊 ### Credentials are no longer printed in command output As of CLI 2.136.8, `org display`, `org list --json`, `org create scratch --json` and several login commands stopped returning secrets. What you get instead is this: ``` "accessToken": "[REDACTED] Use 'sf org auth show-access-token' to view" ``` ### **Use Unverified Email Domains in Scratch Orgs** Not that long ago, Salesforce has introduced a new restriction to its email service restricting users from sending emails from unverified email domains. We all went through this enforcement and activated quite a few DKIM keys for each domain that is being used in the org. That rule makes sense in production, and it is extremely annoying in a scratch org you are going to delete in a few days. So the CLI added a switch for it: ```JSON { "orgName": "Winter27 Unverified Domains", "edition": "Developer", "settings": { "emailAuthorizationSettings": { "enableSubstituteFromAddress": true } } } ``` What I notices is that if you retrieve `EmailAuthorizationSettings` from a Winter ’27 scratch org that was created without that setting, it comes back as `true` anyway. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-8.png) So it is already on by default, and `Messaging.sendEmail` goes through either way. ## SOQL There is exactly one new thing in here, it is a beta, and it does **not** work in any org I could put it into. ### **Compare Values Between Fields in SOQL (Beta)** The feature here is `FORMULA()`, which lets you do arithmetic directly in a `WHERE` clause and compare two fields against each other, without creating a formula field. ```Apex SELECT Id, Name FROM Opportunity WHERE FORMULA(Amount - ExpectedRevenue) > 0 ``` That's good... I'd say if it worked for me. The release notes say the beta is available in sandboxes, Developer Edition and scratch orgs on API 68.0 and later. And yet, if you run that query in a Winter ’27 preview org or in a freshly created Winter ’27 Developer Edition scratch org, every one of those routes gives you the same answer: ``` MALFORMED_QUERY: unexpected token: 'FORMULA(AnnualRevenue -' ``` So I can’t vouch for it in practice yet. But on paper, **it looks really good**, and I will definitely keep an eye on it. ## Lightning Web Components Lightning Web Components did not receive anything dramatically new this time. Instead, this is the release where a couple of things that have been sitting in beta for a while finally became generally available (GA). ### Use Complex Template Expressions (Generally Available) Complex JavaScript expressions inside HTML templates went beta in Spring ’26 and are generally available now. I've already said on LinkedIn that you have to be very careful with this, and it's always better to consolidate logic into JavaScript only, but for small things - I suppose it works. ```HTML ``` More interesting is where the wall actually is. These five are rejected: | Expression | Compiler | | -------------------------- | --------------------------------------------------------- | | {count = 5} | Field mutations are only permitted within arrow functions | | {count++} | Field mutations are only permitted within arrow functions | | {new Date().getFullYear()} | Use of object instantiation is disallowed | | {(count, label)} | Use of comma operators is disallowed | The pattern behind it is actually simple. If an expression mutates state or creates an object - it is rejected. If it only reads and calculates - it is allowed. What the compiler does not check though, is whether the thing you are referencing actually exists. Inside a template expression you only have access to your own component, and every global is undefined in there, including `window`, `document`, `Math` and `JSON`. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-6.png) Template Expression Error So you have to be extremely precise about what you put in those expressions, and reference only the properties that actually exist on your component. ### Use Third-Party Web Components in LWC (Generally Available) You can now render third-party custom elements inside LWC templates without rewriting them, by using `lwc:external`. This one is also generally available with no changes since the last beta. The way it works is you upload the already-built JavaScript file and then load it at runtime with `loadScript` from `lightning/platformResourceLoader`. It has to be a plain browser bundle in [IIFE](https://developer.mozilla.org/en-US/docs/Glossary/IIFE?ref=bulkifiedthinking.com) or [UMD](https://github.com/umdjs/umd?ref=bulkifiedthinking.com) format, because `loadScript` does not support ECMAScript modules, and you cannot import a web component from `npm` into your LWC bundle either. ```JavaScript import { LightningElement } from 'lwc'; import { loadScript } from 'lightning/platformResourceLoader'; import FANCY from '@salesforce/resourceUrl/fancyWidget'; export default class ExternalWidget extends LightningElement { hasLoaded = false; renderedCallback() { if (this.hasLoaded) { return; } this.hasLoaded = true; loadScript(this, FANCY).catch((error) => { // ... }); } } ``` And the template, where the only new thing is the directive itself: ```HTML ``` ### **Control Navigation Items in Lightning Console Apps** The last one is a capability that must have been released waayy back in the day. The `lightning/platformNavigationItemApi` module is the LWC equivalent of the old `lightning:navigationItemAPI` Aura component, and it gives you five methods: ```JavaScript import { getNavigationItems, getSelectedNavigationItem, setSelectedNavigationItem, focusNavigationItem, refreshNavigationItem } from 'lightning/platformNavigationItemApi'; // ... const items = await getNavigationItems(); await setSelectedNavigationItem('standard-Account'); ``` It only works in Lightning console apps, so there is nothing to see outside of one, but if you have been keeping an Aura component alive purely to reach the item menu - you can finally delete it. Farewell, Aura. ## **Where that leaves us** Winter ’27 is a small release with a few genuinely a few good things: a bigger heap, integration tests that talk to external systems, template expressions that I suggest you avoid, and an API that hands your whole type system over to an agent. This is good. This walkthrough was supposed to be good and not too pessimistic, so I kept that promise... So why don’t I leave it entirely up to you? As long as you remember one simple rule: the fact that a feature exists doesn’t mean you should use it. The fact that a feature doesn’t exist doesn’t mean you shouldn’t blame Salesforce. ### Gemini 2.5 Reroute: What Changes in Agentforce, Prompt Builder and Models API URL: https://www.bulkifiedthinking.com/posts/winter27-gemini-reroute-what-changes-in-salesforce/ Last updated: 2026-08-25T02:56:00.000Z While working with AI Agents, there is a popular saying: "Never trust one model to behave exactly like another". This is my saying. It's not popular yet, but now it's destined to be. On October 20, 2026, Google retires Gemini 2.5 Pro, 2.5 Flash, and 2.5 Flash-Lite. Salesforce documents the change in the Winter '27 release notes: **requests to those models get rerouted automatically**. You should pay attention if you're using: - Agentforce agents or subagents with an Agent Script `model_config` pointing to one of the older Gemini 2.5 models. - Prompt Builder templates using Gemini 2.5. - Custom solutions using the Models API with Gemini 2.5. If your org just runs the Salesforce Default model option for Agentforce, you're out of scope. This is about the places where somebody **explicitly** chose Gemini. So if you're using any of these models, test your prompts and Agentforce behavior against the replacement models before the reroute. And if the results disappoint you – choose your own target instead of inheriting the default one. For agents specifically, Salesforce calls out GPT 4.1, Claude Haiku 4.5 and Gemini 3.5 Flash as the models it has "thoroughly" tested. ### Build Better Lightning Web Components with Chrome MCP & LWC MCP URL: https://www.bulkifiedthinking.com/build-better-lightning-web-components-with-chrome-mcp-lwc-mcp/ Last updated: 2026-08-16T23:08:10.000Z One of the most common frustration posts I see on LinkedIn is about User Interface development. The crying that you constantly have to align containers, position them properly, fight SLDS overrides and deal with all that kind of stuff. These people obviously have never heard of such a thing as MCP. There are plenty of MCP servers that can help your agents do more than blindly write code. They can follow Lightning Web Component standards, open the browser themselves, look at what they created and make changes accordingly. And yes, you see where I'm going with this. Today, we're going to cover [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp?ref=bulkifiedthinking.com) and [LWC MCP](https://developer.salesforce.com/docs/platform/lwc/guide/mcp-reference.html?ref=bulkifiedthinking.com) tools: how they can help your agents produce better results without you constantly opening the page, looking at the mess they created, and asking them to move something three pixels to the left. This article is based on my experience working with both MCPs, including a truthful review of whether they actually bring any value and what that "value" looks like in practice. ## What [Chrome MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp?ref=bulkifiedthinking.com) Gives You Often referred to as Chrome DevTools MCP, it gives your agents (Claude, Copilot and others) tools for browser automation, debugging, performance analysis, network inspection, screenshots and more. Basically, this MCP allows your agents to open and navigate a browser, change viewport sizes, take screenshots, evaluate the performance and analyze your Lightning Web Component while they're creating it. In many cases, this results in much better accuracy when following your UI requirements without making you adjust the interface every. single. time. The value of this and other MCPs is that they give the agent a standardized, discoverable set of tools instead of making it invent its own way of controlling the browser. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-3.png) > **Remember:** Your AI agents can make it work without MCP. But at what cost? ## What [LWC MCP](https://developer.salesforce.com/docs/platform/lwc/guide/mcp-reference.html?ref=bulkifiedthinking.com) Gives You This one has a slightly different nature. A large portion of the `lwc-experts` toolset gives your AI agent specialized knowledge about how Lightning Web Components should be built: LWC development guidelines, SLDS, accessibility, Lightning Web Security, compilation errors, testing and best practices. You can almost think of part of it as a collection of skills wrapped behind an MCP interface. Here, you might ask me what the difference is between that and a skill. And I'll tell you. With a skill, the agent is generally given a lot of instructions and knowledge telling it how to approach a particular task. With MCP, capabilities are exposed to the agent as structured tools it can discover and call when needed. Not all at once. Basically, it's another way (better or worse) of working with a large knowledge base without dumping all of it into the model's context at once. ## Connect Chrome MCP You have a few options for connecting this local MCP server. You can read more about local versus remote MCP servers [here](https://modelcontextprotocol.io/docs/2026-07-28/learn/architecture?ref=bulkifiedthinking.com). If you're using Claude Code, one of the easiest approaches is to create a file called `.mcp.json` in your project root and specify the following: ```JSON { "mcpServers": { "chrome-devtools": { "command": "npx", "args": ["-y", "chrome-devtools-mcp@latest"] } } } ``` Afterward, restart your Claude Code / VS Code and approve (if applicable) the MCP server. That's basically it. ### What about Copilot? There is one little distinction worth mentioning here. Claude Code uses: ``` .mcp.json ``` VS Code's native MCP configuration for Copilot is documented as: ``` .vscode/mcp.json ``` It also uses a slightly different top-level structure: ```JSON { "servers": { "chrome-devtools": { "command": "npx", "args": [ "-y", "chrome-devtools-mcp@latest" ] } } } ``` VS Code **can** also discover MCP configurations from other supported clients when MCP discovery is enabled, so depending on your setup, you may be able to reuse the configuration you already have for Claude. ### If you're not Good Enough You can always do it in the not-so-cool way. **Claude Code / VS Code** Go to the “Manage Plugins” window and find the “chrome-devtools-mcp” plugin. Click “Install” and you’re done. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-4.png) **Copilot** Go to the settings gear icon, go to the “Plugins” section on the left, open the marketplace, and search for “chrome-devtools-plugin”. Click “Install” and that’s it. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-5.png) ## Use Chrome DevTools MCP It's quite easy. If you're creating a new Lightning Web Component, all you have to mention is something like: > Verify that it looks good. Or: > Verify that all text is aligned to the center. Now that Chrome DevTools MCP is available, the agent can use its browser tools to open the application, inspect the page, take screenshots and verify the result. It really helps if you happen to have a Canva or Figma design, screenshot, or any other sample showing exactly what the interface is supposed to look like. In those cases, your agent has something concrete to compare its result against. Without a reference, it can still make mistakes and inaccuracies because there is nothing objective to compare the result against. > Leaving your entire User Interface to your agent's judgment is not the best judgment call you've ever made. 0:00 /2:30 1× ## Connect **LWC MCP Tools** > MCP Tools for LWC are still a Beta service as of August 2026 and are subject to Salesforce's Beta Services Terms and all the other bla-bla-bla, so use them at your sole discretion. This MCP might be a little trickier to connect. At least it was for me. Allow me to walk you through the suffering. ### Connection Timeout Just like with Chrome DevTools MCP, if you're using Claude Code, you can create or use an existing `.mcp.json` file in your project root: ```JSON { "mcpServers": { "salesforce-dx": { "command": "npx", "args": [ "-y", "@salesforce/mcp", "--orgs", "DEFAULT_TARGET_ORG", "--toolsets", "lwc-experts,experts-validation", "--allow-non-ga-tools" ] } } } ``` You can absolutely include Chrome DevTools MCP and Salesforce DX MCP **in the same** configuration file. However, this particular setup did **not** work for me on the first try. I'm not saying it will happen to you. It's absolutely worth trying first. It just most certainly failed in my case with a connection timeout. One lesson learned here: if you're using Agentforce Vibes, Salesforce DX MCP is already preconfigured there. Salesforce ships Agentforce Vibes with the DX MCP Server, so adding another entry with the same `salesforce-dx` name can result in a naming collision. So I renamed mine. The name itself doesn't matter. It's just the identifier your MCP client uses for that server. Something like this works perfectly fine: ```JSON { "mcpServers": { "salesforce-something-bla-bla-does-not-matter": { "command": "npx", "args": [ "-y", "@salesforce/mcp", "--orgs", "⚠️ YOUR_DEFAULT_TARGET_ORG_HERE", "--toolsets", "lwc-experts,experts-validation", "--allow-non-ga-tools" ] } } } ``` You can use `DEFAULT_TARGET_ORG` or specify an explicit Salesforce org alias/username. And yet... It still did not work for me. So I went a different route. ### Local Installation Here, we're installing `@salesforce/mcp` directly into the project so that `npx` doesn't have to obtain a temporary/cached copy when Claude starts the server. In my case, the transient `npx` installation was failing with a missing Node dependency, which caused Claude Code to sit there for 30 seconds and eventually give me: ``` MCP server "salesforce-dx" connection timed out after 30000ms ``` Very descriptive. Thank you. Before doing anything else, verify your Node version: ``` node --version ``` I was using Node 22.x while testing this setup. Then install Salesforce MCP into the repository: ``` npm install --save-dev @salesforce/mcp ``` This creates/updates your `node_modules`, `package.json`, and package lock as appropriate. And yes, include `node_modules` in `.gitignore`. Once the package is installed locally, update `.mcp.json`: ```JSON { "mcpServers": { "salesforce-something-bla-bla-does-not-matter": { "command": "npx", "args": [ "@salesforce/mcp", "--orgs", "your-better-ui-lwc", "--toolsets", "lwc-experts", "--allow-non-ga-tools" ] } } } ``` Notice that I removed `-y`. The package already exists in the project's local dependencies, so there is no need for `npx` to auto-confirm obtaining it. After that, restart VS Code/Claude Code and verify the server is connected. > Nothing makes you appreciate an AI productivity tool more than spending half an evening trying to make the AI productivity tool start. ## Use LWC MCP The most important part of using this MCP, which, as you can already tell, contains a lot of expert guidance for AI agents, is making it clear in your prompt what you actually want the agent to optimize for. For example, if you say: ``` Create a new Lightning Web Component as on the screenshot and make it look identical and BE ACCURATE, YOU ARE THE BEST OF THE BEST! ``` In many cases, the agent will just prioritize the requirement you emphasized: visual similarity. If Chrome DevTools MCP is enabled at the same time, it might only use that tool because browser inspection directly helps accomplish what you asked for. Why? Well... Because you said so. Was there anything in your prompt about following LWC development standards? Accessibility? Lightning Web Security? Choosing the correct Lightning Base Components? Avoiding unnecessary custom implementations? No. A better prompt would be something like: ``` Create a new Lightning Web Component based on the screenshot and make it look identical. Use the Salesforce LWC MCP tools to reference the relevant LWC best practices and development guidelines. Follow appropriate SLDS, accessibility and Lightning Web Security practices, and prefer the correct Salesforce development approach over visual hacks used only to match the screenshot. Use Chrome DevTools MCP to verify the final UI. DO IT! ``` You can also explicitly tell the AI agent which MCP server/toolset to use. That's important because simply having an MCP server connected does **not** guarantee that the model will call it for every vaguely related task. The tools are available. The model still decides whether they are useful. Sometimes it needs a little encouragement... or threats. Whatever works. 0:00 /1:35 1× ## Bottom Line All that being said, I have a pretty clear opinion about both of these MCPs and whether you'll actually benefit from using them. [Chrome DevTools MCP](https://developer.chrome.com/blog/chrome-devtools-mcp?ref=bulkifiedthinking.com) is most certainly decent. No matter what kind of UI you're building, giving your AI agent the ability to verify its changes by looking outside the IDE is the way to go. And to be fair, it also has alternatives you might want to consider. One I've had a chance to play with is [Playwright MCP](https://playwright.dev/docs/getting-started-mcp?ref=bulkifiedthinking.com). [Playwright MCP](https://playwright.dev/docs/getting-started-mcp?ref=bulkifiedthinking.com) is particularly strong for browser automation. It exposes 40+ tools for navigation, forms, network mocking, storage, tracing, and other browser workflows, and it supports Firefox, Chrome and Edge. [Chrome DevTools MCP](https://developer.chrome.com/blog/chrome-devtools-mcp?ref=bulkifiedthinking.com), on the other hand, is particularly interesting when you care about Chrome DevTools itself: performance traces, network requests, console errors, screenshots and debugging. As for [LWC MCP](https://developer.salesforce.com/docs/platform/lwc/guide/mcp-reference.html?ref=bulkifiedthinking.com) \- I wasn't that impressed. Yes, it gives your AI agents access to all sorts of Salesforce-specific knowledge: SLDS blueprints, accessibility guidance, Lightning Web Security, LWC development guidelines, Jest tooling, component generation, error references and more. But in my testing, it didn't turn the agent into the kind of developer I'd trust to make any architectural decisions. One of the most frustrating examples for me was module-level functions like this: ```JavaScript function yourFunctionName(value) { // ... } export default class ComponentName extends LightningElement { // ... } ``` Now, to be technically fair, a module-level helper function is **not completely wrong**. Sometimes a small private helper that belongs only to that module is kind of (!) reasonable. The problem starts when the exact same logic appears again... Across different Lightning Web Components. At that point, please **stop**. Put it into a shared JavaScript utility module and import it where you need it. Don't copy the same function into five components. In my testing, the agent repeatedly either created module-level helpers that I didn't want there or duplicated the same utility logic across different LWCs instead of consolidating it. **Don't do that.** Another thing I'd really love to see these tools embrace more aggressively is the new [LWC state-management](https://developer.salesforce.com/docs/platform/lwc/guide/state-management.html?ref=bulkifiedthinking.com) feature. State Managers are now generally available and provide the `@lwc/state` model for encapsulating reactive state and actions, including sharing state across a component tree through context. I've still seen agents build unnecessarily complicated chains where children dispatch events, parents update values, values get passed back through other children, another event comes back and stuff like this. When you're dealing with complex shared state across a component tree, there is now a better native option than building this... whatever. That's really my biggest takeaway from LWC MCP. Giving an AI agent more Salesforce knowledge helps. What do you think? Leave a comment, share your thoughts and **CRITICIZE**. That's why you're here. ### A Practical Guide to Salesforce File Storage Optimization URL: https://www.bulkifiedthinking.com/practical-guide-to-salesforce-file-storage-optimization/ Last updated: 2026-08-16T23:08:22.000Z I must repent: I used to be a sinner. I used to overlook one of the most common problems in the Salesforce ecosystem: File Storage. Storing files directly in Salesforce is convenient. Users upload a file, it appears on the record and nobody has to think about where it actually lives. The problem is that this **convenience gets expensive** very quickly. For reference, 750 GB of additional Salesforce file storage can cost your organization around $45,000 annually. For storing files. With all that in mind, many organizations still make the same mistake and completely avoid thinking about file storage beforehand. A few years later, they are out of storage and suddenly have to redesign the entire way they work with files. Today, we'll look at what we can do about it. > Don't be like us. Be smarter and think about it earlier. ## Salesforce Storage Calculation Let's take Enterprise, Performance and Unlimited editions as an example. The basic calculation looks like this: 🧮 **Total File Storage = 10 GB + (2 GB × number of eligible users)** By default, you get 10 GB. Which is basically nothing. Then Salesforce adds storage depending on the licenses in your organization. The important word here is **eligible**. Storage entitlement depends on licenses, while storage consumption belongs to the entire organization. 📌 **Consumption ≠ entitlement** Even though Salesforce calculates storage using licenses, users do not receive their own individual storage buckets. 📌 **Consumption is org wide.** If your organization has 50 GB of file storage, one user can technically consume almost all of it while another user consumes nothing. Salesforce does not care and you are still out of storage. ## Nobody Likes Integrations Before talking about AWS, Google Cloud or another external system – I'd advise you to take care of what is already inside Salesforce. ### Find What Takes Space This sounds obvious, but before deleting anything, you need to understand where your files are coming from. We'll assume your organization has both legacy `Attachment` records and modern Salesforce Files stored through `ContentDocument`. For Salesforce Files: ```sql SELECT COUNT(Id), LinkedEntity.Type FROM ContentDocumentLink GROUP BY LinkedEntity.Type ``` For legacy Attachments: ```sql SELECT COUNT(Id), Parent.Type FROM Attachment GROUP BY Parent.Type ``` For Salesforce Files, you can also look at download history: ```sql SELECT COUNT() FROM ContentVersionHistory WHERE ContentVersion.ContentDocumentId = :targetDocumentId AND Field = 'contentVersionDownloaded' ``` For legacy Attachments, you do not have the same option. You have to understand the business process and sometimes actually talk to people. Painful, I know. ### Automate Deletion Once you know what can be removed, **automate it**. For example, for modern Salesforce Files, a simple Batch Apex implementation can look like this: ```apex global class YourCleanupBatch implements Database.Batchable { private DateTime cutoff; global YourCleanupBatch(Integer retentionDays) { cutoff = System.now().addDays(-retentionDays); } global Database.QueryLocator start(Database.BatchableContext context) { return Database.getQueryLocator([ SELECT Id FROM ContentDocument WHERE CreatedDate < :cutoff ]); } global void execute(Database.BatchableContext context, List scope) { Database.delete(scope, false); } global void finish(Database.BatchableContext context) {} } ``` This part is easy. Figuring out what you are actually **allowed to delete** is not. ### Watch File Versions A single Salesforce File can have multiple `ContentVersion` records. Every time somebody uploads a new version, Salesforce keeps the previous version as well. More often than not, somebody uploads a 20 MB presentation ten times and congratulations, you now have roughly 200 MB of presentation history. Include file versioning in your storage analysis: If your users actually need the history, keep it. If they do not, remove previous versions. ### Compress Files Salesforce now provides [native ZIP functionality](https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex%5Fnamespace%5Fcompression.htm?ref=bulkifiedthinking.com) through the Apex `Compression` namespace. You can take binary files, create a ZIP archive and store the compressed result back in Salesforce. There is a problem though. Once the file becomes a ZIP, your normal preview experience is gone. And Apex heap limits **still exist**. The Compression API does **not** make Apex capable of processing huge files. Apex still has to hold the original file and the compressed archive in a single transaction. That said, files around 7 MB can still be compressed in asynchronous transaction, where the heap size limit is 12 MB. Keep in mind that 7 MB is just rough practical range that still leaves some heap for the compression itself. Synchronous transactions have only 6 MB of heap, so the same approach will not work there for files of that size. And don't forget that the files add up. Two 7 MB files already require roughly 14 MB just for the original binary data, before Apex even creates the compressed archive. That is already above the 12 MB asynchronous heap limit and will fail. Now, practically speaking, here's how to compress modern Salesforce files: ```apex public static void compressFile(Id contentDocumentId, Id recordId) { List versions = [ SELECT Title, FileExtension, VersionData FROM ContentVersion WHERE ContentDocumentId = :contentDocumentId AND IsLatest = true LIMIT 1 ]; if (versions.isEmpty()) { return; } ContentVersion version = versions[0]; Compression.ZipWriter writer = new Compression.ZipWriter(); String originalName = version.Title + version.FileExtension; // Add the original file into the ZIP writer.addEntry( originalName, version.VersionData ); ContentVersion zipped = new ContentVersion( Title = version.Title, PathOnClient = version.Title + '.zip', VersionData = writer.getArchive(), FirstPublishLocationId = recordId ); insert zipped; // Delete only after the ZIP exists delete new ContentDocument( Id = contentDocumentId ); } ``` Your implementation will also need to preserve all required record links, sharing and other file relationships before deleting the original. ### Files Connect Salesforce also has a built in feature called [Files Connect](https://resources.docs.salesforce.com/latest/latest/en-us/sfdc/pdf/salesforce%5Ffiles%5Fconnect%5Fimplementation.pdf?ref=bulkifiedthinking.com). It allows users to access files stored in systems such as Google Drive, SharePoint, OneDrive and Box from Salesforce. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image.png) You can enable it from: `Setup > Files Connect > Edit > Enable Files Connect` Files Connect supports two important modes: - **Copy** brings a copy of the external file into Salesforce. That obviously does not solve your Salesforce storage problem. - **Reference** keeps the actual file outside Salesforce and stores a reference to it instead. That is the one we care about here. The **Reference** is the one we care about here. The external system still owns and controls the file, while Salesforce provides access to it. For some organizations, this is enough. For others, it creates another problem: Your files still belong to whatever external account, user or system owns them. You never establish ownership over these files. If this person deletes the file from their storage or gets fired - the link is **gone**. If it does not work for you, then you have two realistic options: 1. Build your own integration with external storage. 2. Use a product somebody already built. And realistically speaking, if your requirements are not crazy and your business mostly needs upload, download and preview (simple operations), this is absolutely something a Salesforce team can build. You don't need a product doing this for you just so you can keep paying for something you could take care of once and for all. > Long term suffering **vs** short term sacrifice. > Choose the latter. ## Everybody Likes Integrations Now we get to AWS S3 and Google Cloud Storage. There are other options, but we'll use these two as our reference throughout this article. They both follow the same general object storage model. You have buckets, objects, keys, metadata, uploads, downloads and lifecycle rules. > **Important:** > > Before touching Salesforce, define your buckets, permissions, retention rules, lifecycle policies, encryption, object naming and CORS configuration. > > And definitely **define retention at this point**. > > Both platforms already give you the tools to automatically remove or transition objects after a certain period. Use them. ### Backend Architecture Considering that storage providers can change and at some point you might rethink what you're using, I strongly recommend applying [dependency inversion](https://stackify.com/dependency-inversion-principle/?ref=bulkifiedthinking.com) here. Start with a small interface that defines what your application actually needs from a storage provider: ```apex public interface ObjectStorageProvider { String upload(String objectName, Blob body, String contentType); Blob download(String objectName); void deleteObject(String objectName); } ``` That's all Salesforce needs to know. It knows that a file can be uploaded, downloaded and deleted. How Google Cloud Storage or AWS S3 performs those operations belongs to the implementation behind this interface. > **Important:** > > Object names identify files inside the bucket and uploading another file under the same name can replace the existing object. > > Generate your own unique object name and keep the original filename separately in Salesforce. Now we can create the Google Cloud Storage implementation: ```apex public with sharing class GcsStorageProvider implements ObjectStorageProvider { private static final Storage_Config__mdt CONFIG = Storage_Config__mdt.getInstance('GCS'); public String upload(String objectName, Blob body, String contentType) { HttpRequest request = new HttpRequest(); request.setEndpoint( 'callout:GCS/upload/storage/v1/b/' + CONFIG.Bucket__c + '/o?uploadType=media&name=' + EncodingUtil.urlEncode( objectName, 'UTF-8' ) + '&ifGenerationMatch=0' ); request.setMethod('POST'); request.setHeader( 'Content-Type', contentType ); request.setBodyAsBlob(body); HttpResponse response = new Http().send(request); Map result = (Map) JSON.deserializeUntyped(response.getBody()); return (String) result.get('name'); } public Blob download(String objectName) { // ... } public void deleteObject(String objectName) { // ... } } ``` Notice the `ifGenerationMatch=0` at the end of the upload URL. Even though we're already generating unique object names, I would still keep this protection. It tells GCS to create the object only if no live object already exists under that name. If somehow we generate a collision, the request fails instead of replacing somebody else's file. Another thing to keep in mind is that authentication belongs in Named Credentials and External Credentials, while configuration that is safe to store as metadata goes into Custom Metadata. For example: ```text Provider: GCS Bucket: company-salesforce-files Root Path: production/ Maximum File Size: 500 MB ``` The bucket name, root path and file size limit are configuration. Service account private keys, AWS secret keys and access tokens are credentials and must not live there. Now we put the provider behind a service: ```apex public with sharing class FileStorageService { private ObjectStorageProvider storage; public FileStorageService(ObjectStorageProvider storage) { this.storage = storage; } public String upload(Id recordId, String fileName, Blob body, String contentType) { String objectName = createObjectName(recordId, fileName); return storage.upload( objectName, body, contentType ); } private static String createObjectName(Id recordId, String fileName) { // unique name } } ``` For small files, the controller now look something like this: ```apex @AuraEnabled public static String upload(Id recordId, String fileName, String base64Data, String contentType) { Blob body = EncodingUtil.base64Decode(base64Data); FileStorageService service = new FileStorageService( StorageProviderRouter.get() // Decides which provider to use, GCP or AWS, based on a custom metadata field ); return service.upload( recordId, fileName, body, contentType ); } ``` And I want to emphasize **small files** here. Passing Base64 from LWC into Apex is absolutely not the approach we'll use for large uploads. We'll get to those shortly. With this approach, if tomorrow you decide to move to AWS S3, you create another class implementing exactly the same interface: ```apex public with sharing class S3StorageProvider implements ObjectStorageProvider { public String upload(String objectName, Blob body, String contentType) { HttpRequest request = new HttpRequest(); request.setEndpoint( 'callout:S3/' + objectName ); request.setMethod('PUT'); request.setHeader( 'Content-Type', contentType ); // Never replace an existing object request.setHeader( 'If-None-Match', '*' ); request.setBodyAsBlob(body); HttpResponse response = new Http().send(request); return objectName; } public Blob download(String objectName) { // ... } public void deleteObject(String objectName) { // ... } } ``` Then your router decides which implementation gets used: ```apex public class StorageProviderRouter { public static ObjectStorageProvider get() { String provider = Storage_Config__mdt.getInstance('Default').Provider__c; if (provider == 'S3') { return new S3StorageProvider(); } else if (provider == 'GCS') { return new GcsStorageProvider(); } // ... } } ``` That's the main benefit of doing this properly. Your controller, UI and business logic don't need to know how GCS works or how AWS signs a request. If the provider changes, you replace the implementation instead of rewriting the application. ### Frontend Architecture The next question is how your users actually work with these files. Since the files no longer live in Salesforce, the standard Files component will not represent your external storage anymore. The most realistic approach is to build a Lightning Web Component that replaces that experience. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/08/image-1.png) Your component queries `ExternalFile__c` custom records and shows the information users actually care about: filename, type, size, created date and whatever else makes sense for your organization. The important part is that your UI works with the Salesforce record Id, not the GCS object name or S3 key. For example: ```apex @AuraEnabled(cacheable=true) public static List getFiles(Id recordId) { return [ SELECT Id, FileName__c, MimeType__c, Size__c FROM ExternalFile__c WHERE ParentRecordId__c = :recordId WITH USER_MODE ]; } ``` Notice that `ObjectId__c` is not returned to the component because it has absolutely no reason to be there. When a user clicks Download, Preview or Delete, send the Salesforce Id back to Apex. Apex resolves the object key, checks whether the user is allowed to perform that operation and only then talks to external storage. Your actual flow now looks like this: `LWC → Controller → File Service → Storage Provider → External Storage` This also gives you one place to enforce security instead of trusting every component that happens to know where a file lives. ### File Preview While implementing your own file interface, previewing files will most likely become the next challenge. Take my word for it. S3 and Google Cloud Storage store your bytes. They don't take a Word document and somehow give you a browser preview of it. For formats that browsers cannot display themselves, the cleanest approach is to generate a PDF preview. Keep the original file untouched for downloading and create another object containing the preview. You can then load [pdf.js](https://mozilla.github.io/pdf.js/?ref=bulkifiedthinking.com) into Salesforce as a Static Resource and use it from your Lightning Web Component to render the PDF. > **Note:** Images are easier. If the file is an image, there is no reason to convert it into PDF at all. Instead, download it and use a normal `` element. ### Large File Uploads Now we get to one of the most important parts of the entire implementation. If your users upload large files, **chunk them in JavaScript**. Do **not** take a 500 MB file, convert it into Base64 and send it into Apex. Salesforce limits will stop you long before you get anywhere. The browser already has the file as a `File` or `Blob`, so let JavaScript split it into smaller pieces and send those pieces directly to the storage provider. AWS calls this **Multipart Upload** while Google Cloud Storage calls it **Resumable Upload**. Their APIs are different, but the general idea is almost identical: create the upload, send the file in chunks, retry failed chunks if applicable and complete the upload at the end. ### Secure Architecture 1️⃣ **Never expose cloud credentials to JavaScript.** Your LWC does not need an AWS secret key or a Google service account credential. It gets temporary authorization for the specific upload or download that the user is allowed to perform. 2️⃣ **Check Salesforce access before creating that authorization.** Knowing an `ExternalFile__c` Id does not give somebody permission to download it. Apex first checks record access and your file permission model, then resolves the storage key and creates temporary access. 3️⃣ **Keep temporary authorization short lived.** An S3 presigned URL grants access until it expires. A GCS resumable session URI grants access to that upload session. Give the browser only that and nothing more. 4️⃣ **Do not trust file extensions.** A file called `invoice.pdf` is not necessarily a PDF. Read the actual file signature and validate enough bytes to identify the format correctly. For example: ```text PDF: 25 50 44 46 PNG: 89 50 4E 47 JPEG: FF D8 FF ZIP: 50 4B 03 04 ``` 5️⃣ **Use checksums.** Verify that the object stored externally is the same binary content that was uploaded. Checksums also give you a way to identify exact duplicates if you ever decide to go down that path. Probably don't build an entire deduplication empire because I mentioned it, just a thought. 6️⃣ **Enforce a maximum file size.** Check it in JavaScript so users receive an immediate message and enforce it again on backend because client side validation can always be bypassed. 7️⃣ **Keep storage identifiers away from the UI.** Your component works with an `ExternalFile__c` Id. The backend knows the S3 key or GCS object name. Temporary signed URLs are obviously sent to the browser because that is exactly what they exist for, but they expire and cannot be reused. 8️⃣ **Configure both Salesforce CSP.** A Lightning Web Component cannot simply start calling any external endpoint it wants. Add the required storage domain as a Trusted URL in Salesforce and configure CORS on S3 or GCS for the Salesforce domains that need access. Salesforce explicitly requires trusted URLs for third party JavaScript calls and the external service still needs to allow the Salesforce origin through [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS?ref=bulkifiedthinking.com). ## Migrate Existing Files Now you've built the new architecture and everything looks great, but the hundreds of gigabytes already sitting in Salesforce haven't just disappeared. Therefore, for a real migration, use an external Node.js or Python process that can retrieve the Salesforce file as a stream and send that stream directly into the destination storage. That's pretty much the only way you have. The migration itself follows a very predictable process: 1. Query the Salesforce files you want to migrate. 2. Retrieve the latest `ContentVersion` or every version you need to preserve. 3. Stream `VersionData` from Salesforce. 4. Stream the same data into S3 or GCS. 5. Verify the destination object. 6. Create your `ExternalFile__c` record and preserve its Salesforce relationships. 7. Mark the migration as completed. 8. Delete the Salesforce copy only after everything above succeeded. ## Existing Solutions Considering everything we've discussed so far, the reasonable question is whether somebody can just do all of this for you. The answer is **yes**. There are products that replace Salesforce file storage with external storage and provide their own components around it. Some also give you functionality that would take significantly more work to build yourself. I won't spend half of the article comparing them because nobody paid me to do so, but there is one thing I would pay a lot of attention to: **migration**. If you're already sitting at 100% Salesforce storage, a product that only takes care of tomorrow's uploads does not solve today's problem. Ask whether it migrates existing Salesforce Files, whether it understands legacy Attachments, whether it preserves multiple versions and record relationships. You are paying somebody specifically so you don't have to build all of this yourself. Make sure they actually take the difficult part away. For reference, I've recently come across [Sliick Files](https://appexchange.salesforce.com/appxListingDetail?listingId=297f3767-6236-4b90-8bae-f01e54fae2e2&ref=bulkifiedthinking.com). No advertisement, but if you work there, here is the link for your donation for me mentioning it: Sliick provides its own Salesforce file interface and supports bring your own storage with providers including Amazon S3, Google Cloud, Azure and SharePoint. This is exactly the kind of architecture I'd look for when evaluating products in this area. Storage belongs to you, Salesforce remains the business layer and users don't have to care where the bytes physically live. Migration is still something I would specifically validate during the evaluation because this is where the actual storage problem gets solved. ### Google Client for Salesforce A quite different option is [Google Client for Salesforce](https://sandriiy.github.io/salesforce-google-client/?ref=bulkifiedthinking.com), a free and open source solution that replaces Salesforce file storage with Google Drive and replaces the normal Salesforce file experience with its own components. Google Drive is not an object storage system like AWS S3 or Google Cloud Storage. It is a file and document platform, which naturally gives you more capabilities around the files themselves. You already have folders, file permissions, shortcuts, Google native documents and APIs built specifically around people working with files. [Google Client](https://sandriiy.github.io/salesforce-google-client/?ref=bulkifiedthinking.com) uses those capabilities to provide things such as automatic folder structures, file intelligence, AI functionality and the rest of the file management experience. Its folder structure, for example, can automatically organize uploaded files by Salesforce user, record or both. The current open source project does not migrate your existing Salesforce Files, so that part still remains something you have to handle separately. It's painful, I know. That's all for today. Be mindful about your decisions and remember that whatever decision you make, never admit it's a bad decision. Therefore, people will admire you. ### Design Your Org The Right Way URL: https://www.bulkifiedthinking.com/salesforce-design-your-org-the-right-way/ Last updated: 2026-08-16T23:08:42.000Z The long-term stability of your Salesforce implementation is determined before you implement anything. It begins with an architecture that serves the business and works for the developers responsible for building and extending it. An architecture created for developers is destined to fail. Despite the popular assumption that architecture should be defined through technical elegance and maintainability, you will achieve neither by neglecting the business you are building for. My architecture playbook consists of three decisions: 1. **Topology:** Where should each part of the solution live? 2. **Separation:** What should each part own and be responsible for? 3. **Framework:** How should teams implement those parts consistently? Get these three decisions right, and a change in one capability should not require full regression testing of everything around it. --- ## The Right Topology Topology depends on whom we are building the Salesforce implementation for, how many business units or products are involved, and how much coordination the business expects between them. At this point, do not dive into technical details. I need you **to forget** about SObjects, Apex, Agentforce, Platform Events and everything else that makes you feel productive. **Your first goal is to understand what the business actually does.** One of the most common requests you will hear is: > We'd like to keep everything in one Salesforce org so that all our teams can easily coordinate with each other. “Coordinate with each other” may be the most misleading phrase you will hear during architecture planning. That simplified coordination is exactly what can make your days, nights and weekends so enjoyable. The truth is that the topology decision rests on two elephants: - **Compliance** - **Boundaries** ### Compliance Start by identifying whether the implementation contains highly regulated data or processes. This may include [PII](https://en.wikipedia.org/wiki/Personal%5Fdata?ref=bulkifiedthinking.com), financial data, health information, confidential documents or anything governed by strict access, audit, retention, residency or operational requirements. Then ask whether those capabilities should live in the same environment as rapidly evolving areas such as Sales, Marketing or Customer Support. Compliance does not automatically require another Salesforce org. Sometimes a strong access model, encryption (hi [shield](https://www.salesforce.com/ca/platform/shield/?ref=bulkifiedthinking.com)) and logical separation inside one org are good enough. In other cases, the regulatory requirements and operational ownership are so strict that a separate org becomes the only choice you have. That said, even when regulations do not explicitly demand physical separation, mixing heavily regulated capabilities with rapidly changing ones will slow delivery and make the organization unnecessarily complicated. In my experience, teams end up creating an entire custom structure only to accommodate one regulated capability. For reference – external file storage (AWS, GCP). Regular files and files containing regulated personal data may require completely different access controls, retention policies, deletion processes, audit histories and storage locations. Eventually, you create separate storage, separate permissions, separate automation and separate operational procedures. At some point, you should ask whether forcing them into the same architecture is still simplifying anything. ### Boundaries The second elephant is boundaries. Two separate organizations naturally provide stronger physical boundaries. They also introduce more integrations, duplicated administration, separate deployments and additional support effort. That brings us to the real question: > Can a single Salesforce org have clear boundaries? I say it can. It depends on the business and on how many independent products, units or capabilities the org must support. Ask yourself: > If I change Product A, will I need to perform regression testing on Product B? If the answer is no, ask the more difficult question: > Can our development team keep it that way as both products continue to evolve? Whether we like it or not, architecture is not only about the business or the architect. It is also about the people who will maintain the solution. Sometimes it is not about the brilliance of your diagram. It is about the developers sitting at the desks around you who keep interrupting you. A boundary that exists only in an architecture document is not a boundary. Understanding whether your organization can preserve those boundaries is part of being a good architect. If the boundaries cannot be protected inside one org, you must return to the business and explain the real cost of keeping everything together: - coupling between unrelated capabilities - shared-code collisions - larger regression cycles - coordinated releases - growing operational risk > When that cost becomes greater than the cost of splitting the solution, the solution should be split. Compliance and boundaries are not the only topology considerations. People will throw such phrases at you as [*Inverse Conway Maneuver*](https://www.thoughtworks.com/en-ca/insights/blog/customer-experience/inverse-conway-maneuver-product-development-teams?ref=bulkifiedthinking.com), [*cognitive load*](https://thevaluable.dev/cognitive-load-theory-software-developer/?ref=bulkifiedthinking.com) and [*fracture-plane criteria*](https://docs.itascasoftware.com/itasca900/common/dfn/doc/dfn%5Fmanual/dfn%5Fcommands/cmd%5Ffracture%5Fcombine.html?ref=bulkifiedthinking.com). Those concepts are good, yet my practical position is simpler: > If compliance is manageable and the boundaries are clear and enforceable, you can build an excellent architecture inside a single Salesforce org. ## The Right Separation Once the topology is understood, the next step is separation. I prefer to explain this stage through the [**Separation of Concerns**](https://drive.google.com/file/d/1FZDfRu7AOFy0FRgNUIa7ukO7KD5tQm5E/view?usp=sharing&ref=bulkifiedthinking.com), or SoC principle. SoC means dividing a solution into distinct parts, with each part addressing a separate concern. Many developers already apply this principle at the implementation level. They ask: - Should querying be placed in a selector? - Should email delivery have its own service? - Should price calculation live in a separate class? Those are valid applications of SoC, but they are too detailed for this stage. At this point, **forget about all of this**. Instead, separate the operational concerns of the business by asking: > Which teams and capabilities are involved, and what does each of them own? Your initial capability may include: ``` Customer Management Human Resources Customer Support Billing Document Management ``` This is also the moment to identify external providers that support those capabilities. For example: ``` Billing -> Stripe Human Resources -> Workday Document Management -> Conga Composer ``` The provider itself does not necessarily define your capability. Stripe may process payments, but your Billing capability must still own the business meaning of an invoice, a credit or an outstanding balance. Once the capabilities are identified, answer the following questions: - Which business responsibilities belong to each capability? - What data does each capability own? - Which capability may modify which records? - Who is the business & technical owner? - What behavior does the capability expose to others? - What information does it receive from others? - What outcomes does it publish back? Pay particular attention to the idea of **exposing behavior**. The goal is to define each capability so that it can operate independently. Other capabilities should request its behavior instead of reaching into its internal data and implementing its rules themselves. I have seen the opposite many times: *A support user updates a customer’s phone number and receives an error such as:* > *This customer cannot be updated while an invoice is being processed.* *The support user has nothing to do with invoicing. They did not modify an invoice, initiate a payment or request a billing adjustment. And yet, a Billing concern has leaked into an unrelated operation and blocked it.* You may argue that this problem should be addressed later through the framework and code. **No, it should not.** It should be addresses right now. If the capability separation is wrong, no framework will rescue the implementation without forcing you to reconsider the separation first. ### Defining a Billing Capability Suppose we are defining Billing. Its purpose might be: > Manage invoices, charges, credits, outstanding balances and payment status. It may own: ``` Invoice Invoice Line Billing Account Credit Adjustment Payment Status ``` Its business owner may be: ``` Finance Operations ``` It may expose operations such as: ``` Create Invoice Issue Credit Cancel Draft Invoice Get Outstanding Balance ``` It may publish outcomes such as: ``` Invoice Issued Credit Issued Invoice Cancelled Payment Status Changed ``` At this stage, we are not deciding whether Billing uses: - Layered Architecture - Platform Events - Apex or Flow - Selectors We are defining **what Billing is**. The resulting capability boundary may look like this: ``` Billing ├── Invoice Management ├── Credit and Adjustment Management ├── Billing Account Management └── Payment Status Tracking ``` This tells us what belongs inside the Billing boundary. It also helps us recognize what does **not** belong there. This is one of the most important parts of architecting a solution. > Good architects identify components. Great architects identify boundaries, ownership and the consequences of crossing them. ## The Right Framework Once the capability boundaries are defined, the framework answers the next question: > How will teams consistently implement each capability without destroying the boundaries we just created? A framework is **not** a collection of abstract classes, selectors, services and impressive naming conventions. It is an implementation model. It tells developers how a request enters a capability, where orchestration happens, where business decisions live, how data is accessed, how external systems are called, how transactions are controlled, and which dependencies are allowed. A generic execution model might look like this: ``` Entry Point ↓ Application Operation ↓ Business Rules ↓ Data or External Systems ``` In Salesforce, an entry point may be: ``` LWC Flow Trigger Scheduled Job Invocable Apex ``` The entry point should translate the incoming request and invoke an application operation. For example: ``` Issue Invoice Approve Credit Close Case Confirm Order Generate Document ``` The application operation coordinates the use case. It loads the required information, invokes business rules, manages the transaction, persists the result and communicates the outcome. The business rules decide what is allowed. The data and integration boundaries handle technical work such as SOQL, DML and external APIs. This sounds fancy, but **the truth is that the exact names don't matter at all**. You can call them services, use cases, domains, handlers, repositories, selectors or gateways. What matters is that their responsibilities **are understood** and consistently applied. ### Framework Decisions When I define a framework, I want it to answer questions such as: - What is an entry point allowed to do? - Where does business orchestration happen? - Where do business rules live? - Who controls the transaction? - Where are SOQL and DML performed? - How do capabilities communicate? - How are security and permissions enforced? - How are errors translated and logged? - How are dependencies replaced during testing? - How do we extend behavior without filling the solution with `if-else` conditions? ### Applying the Framework to Billing Suppose another capability needs Billing to issue an invoice. From the outside, the interaction begins with an operation exposed by Billing: ``` Request Invoice ``` That operation represents the capability contract. Sales may request an invoice, but it should not create `Invoice__c` records, calculate invoice totals or decide whether an order is eligible for billing. The technical entry point depends on how the request reaches Salesforce. It could be: Apex Controller or Flow Action or Trigger Handler. For example: ```apex public with sharing class BillingController { @AuraEnabled public static InvoiceResult requestInvoice(Id orderId) { return BillingApplication.requestInvoice(orderId); } } ``` The controller does not contain Billing logic. It receives the request and passes it to the application operation responsible for the use case. ```apex public with sharing class BillingApplication { public static InvoiceResult requestInvoice(Id orderId) { Order orderRecord = OrderSelector.selectById(orderId); InvoiceDraft draft = InvoiceDomain.createInvoice(orderRecord); Invoice__c invoice = InvoiceRepository.doInsert(draft); BillingEventPublisher.publishInvoiceIssued(invoice); return InvoiceResult.fromRecord(invoice); } } ``` The implementation flow now becomes more concrete: ``` BillingController.requestInvoice() ↓ BillingApplication.requestInvoice() ↓ InvoiceDomain.createInvoice() ↓ InvoiceRepository.doInsert() ↓ InvoiceIssued Event ``` Each part has a specific responsibility. The entry point translates the external request. The application operation coordinates the use case. The domain component applies Billing rules. The repository handles persistence. The event publisher communicates the completed outcome. The eligibility and calculation rules could look something like this: ```apex public class InvoiceDomain { public static InvoiceDraft createInvoice(Order orderRecord) { if (orderRecord.Status != 'Activated') { throw new BillingException( 'Only activated orders can be invoiced.' ); } if (orderRecord.TotalAmount <= 0) { throw new BillingException( 'An invoice total must be greater than zero.' ); } return new InvoiceDraft( orderRecord.Id, orderRecord.AccountId, orderRecord.TotalAmount ); } } ``` The important part is not the class names. Another framework may call these components services, use cases, domains, selectors, repositories or handlers. What matters is that the responsibilities remain separate. A Flow action, trigger handler or event subscriber should still invoke the same application operation: ```apex BillingApplication.requestInvoice(orderId); ``` The entry point may change. **The Billing rules should not.** #### External Payment Providers The same rule applies when Billing communicates with an external payment provider. Billing should understand business operations such as: ``` Authorize Payment Capture Payment Refund Payment ``` It should not spread Stripe-specific request objects, status codes and API terminology throughout the capability. Instead, the framework can define an interface owned by Billing: ```apex public interface PaymentGateway { PaymentResult capturePayment( Decimal amount, String currencyCode, String paymentReference ); } ``` A Stripe-specific adapter implements that interface: ```apex public class StripePaymentGateway implements PaymentGateway { public PaymentResult capturePayment(Decimal amount, String currencyCode, String paymentReference) { StripeCaptureRequest request = new StripeCaptureRequest(); request.amount = amount; request.currency = currencyCode; request.reference = paymentReference; StripeCaptureResponse response = StripeClient.capturePayment(request); return new PaymentResult( response.isSuccessful, response.transactionId, response.failureMessage ); } } ``` The Billing operation depends on the business-facing interface rather than directly on Stripe: ```apex public class PaymentApplication { private PaymentGateway gateway; public PaymentApplication(PaymentGateway gateway) { this.gateway = gateway; } public PaymentResult captureInvoicePayment(Invoice__c invoice) { return gateway.capturePayment( invoice.Balance__c, invoice.CurrencyIsoCode, invoice.Id ); } } ``` The dependency now looks like this: ``` Billing Operation ↓ PaymentGateway ↓ StripePaymentGateway ↓ Stripe API ``` Billing understands the business operation. The adapter understands Stripe. That distinction matters. Without it, external API terminology, response formats, authentication logic and provider-specific behavior eventually spread throughout the capability. Replacing Stripe then becomes less of an integration change and more of an organizational crisis. > A good framework does not avoid technical complexity. It gives that complexity an explicit place to live. ## **The Right Guardrails** - Triggers do not contain business logic. - UI controllers do not orchestrate complete business processes. - Capabilities do not directly modify another capability’s owned data. - Business rules do not depend on a specific UI or integration. - External providers are accessed through explicit boundaries. - All collection-based entry points must remain bulk-safe. FFLIB, AWAF and similar approaches can help implement parts of this model. They provide patterns, abstractions and conventions that prevent every team from inventing its own structure. But choosing a framework should never begin with: > Should we use FFLIB? It should begin with: > What implementation behavior must be consistent across our solution? Only then can you decide whether an existing framework supports your model, whether it must be adapted or whether a simpler custom structure is enough. At the end of the day, always keep it simple. ### Stop Writing Rigid Apex Code URL: https://www.bulkifiedthinking.com/stop-writing-rigid-apex-code/ Last updated: 2026-08-16T23:03:44.000Z "In object-oriented programming, **polymorphism** is the provision of a single interface to entities of different types" – [Wikipedia](https://en.wikipedia.org/wiki/Main%5FPage?ref=bulkifiedthinking.com) In my experience, polymorphism is the provision of a single definition to engineers who have no idea what that definition means. And I'm not saying that learning a term is a bad habit, but rather that the meaning behind it is more important than the definition itself. Continuing this debate, when asked to provide examples of polymorphism, the answers often boil down to "Method Overriding" and "Method Overloading". But we're still not addressing what polymorphism actually is. I could use method overriding my entire life without realizing whether it's inheritance, polymorphism, both, or neither. --- ## Method Overloading Method overloading in Apex allows the creation of multiple methods with the same name but different parameters. This is particularly useful when you have similar logic that applies to different parameter types. Instead of creating separate methods with distinct names, you can combine them under a single method name. For example, consider the Apex [String](https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex%5Fmethods%5Fsystem%5Fstring.htm?ref=bulkifiedthinking.com) class, which includes an overloading of the `valueOf()` method. Depending on the type of the variable passed, different operations are performed, all while using the same method name. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-7.png) In this case, the overloaded methods are defined **at compile time**. The methods are explicitly declared, allowing you to easily predict the outcome during code execution. This form of polymorphism is known as "Static Polymorphism". While it’s true that here polymorphism refers to these "different forms", the true beauty of polymorphism reveals when the behavior of the code becomes less predictable. ## Method Overriding Method Overriding represents a more interesting structure, but we often demonstrate it incorrectly, why so? This example involves inheritance, another fundamental concept of Object-Oriented Programming (OOP) and that's why method overriding is often mistakenly presented as an example of inheritance rather than a true example of "Dynamic Polymorphism". To begin, let's create a class called `Parent`. It will include a constructor to specify the age and a method that we will attempt to override in the child class. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-8.png) As you may have noticed, we used the `virtual` keyword for both the class and the method. Why is this necessary? In Apex (unlike Java), all classes and methods are final by default. Therefore, if you want to extend a class, such as inherit from it or override a method — you must use the `virtual` keyword to explicitly indicate that the class or method is intended for extension. In the next step, we will create a `Child` class that extends the `Parent` class and overrides the `giveAdvice()` method that you saw earlier in the parent class. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-9.png) Finally, we come to the point of method overriding, where we have two classes, each with the same method but different logic inside. Sounds like polymorphism, correct? Well, not really. Do you remember I mentioned earlier that this example is often presented incorrectly? This is that moment. After implementing the second class and overriding the method, many show the code and say, "Look, this what Dynamic Polymorphism is'. No, it’s not. You’ve explicitly defined two classes and their logic and how the hell is it supposed to be something not very predictable? True **dynamic polymorphism** is only demonstrated when you show how it’s used in practice. Specifically: ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-10.png) In the first method, we introduce exactly this "Dynamic Polymorphism". This is because, even though we specify an instance of the "Parent" class as a parameter, Apex allows us to pass any extended class as well, like this: In the first version of the method, we introduce this "Dynamic Polymorphism". Even though the `extractAdvice()` method accepts an instance of the `Parent` class, Apex also allows us to pass an instance of any class that extends `Parent`, like this: ```apex Parent newChild = new Child(5); extractAdvice(newChild); ``` We create a `Child` instance using `new Child(5)`, but store it in a variable declared as `Parent`. This is possible because every `Child` is also a `Parent`. When `extractAdvice()` calls `giveAdvice()`, Apex determines the actual type of the object at runtime. Since the object is a `Child`, Apex executes the overridden `Child.giveAdvice()` method, which returns `"Have Fun"`. This behavior is called "Dynamic Polymorphism" (or runtime polymorphism). In the second version of `extractAdvice()`, we use `if-else` conditions to determine which code should be executed. This is not polymorphism. You can create the correct class hierarchy but still use it incorrectly by manually checking object types instead of allowing Apex to select the appropriate implementation at runtime. ## Dynamic Instantiation While discussing the topic of Dynamic Polymorphism, I would also like to touch on instantiating a class at runtime. Some argue that this is not strictly part of polymorphism, though I disagree. Consider the case where we have a class name represented as a string, like this: ```apex String className = 'MyClass'; ``` How would you create an instance of this class in this scenario? Salesforce provides us with a great class called [Type](https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex%5Fmethods%5Fsystem%5Ftype.htm?ref=bulkifiedthinking.com) that allows us to do this easily and seamlessly. Let's put it into practice: ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-11.png) In this example, we pass our class name to the method as a string, and using the [Type](https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex%5Fmethods%5Fsystem%5Ftype.htm?ref=bulkifiedthinking.com) class, we can instantiate it with a combination of the `getName()` and `newInstance()` methods. But wait... If we instantiate this class dynamically, how did I manage to specify the name `GoogleAuthorizer` statically? How did I know? It’s all thanks to the fact that `GoogleAuthorizer` is not a class, but an interface. This means that when using this method, we must ensure that any class passed as a string strictly adheres to this particular interface. This allows us to predict exactly what methods it contains. In this case, I am confident that the passed class will include the method `retrieveAccessToken()` because it is part of the interface, and as we know, everything defined in an interface must be implemented by the class that implements it. ## What Exactly is Polymorphism? As you may have noticed, it comes in two types: Static Polymorphism and Dynamic Polymorphism. Furthermore, the term "Polymorphism" is not limited to "different forms"; it represents a structure in which our code becomes less predictable and more generalized, making it easier to reuse and more flexible to extend. ### Salesforce LWC Performance Journey: From Basic Apex Calls to Optimized UI #TND26 URL: https://www.bulkifiedthinking.com/salesforce-lwc-performance-journey-from-basic-apex-calls-to-optimized-ui-tnd26/ Last updated: 2026-08-16T23:04:41.000Z This article builds on the material I present at [True North Dreamin’](https://www.truenorthdreamin.com/?ref=bulkifiedthinking.com), held in Toronto on May 11–12, 2026. I’ll start by showing you a [Lightning Web Component solution](https://github.com/sandriiy/tnd26-lwc-opportunity-radar/tree/problem?ref=bulkifiedthinking.com) built without any modern platform capabilities, one that calls Apex for everything, just like all the components you create. Then **we’ll fix it**. Step by step, we’ll introduce capabilities that are brand new (`@lwc/state`, `Database.PaginationCursor`), ones that have always been best practice forever (`lightning/uiRecordApi`), and one you probably haven't considered at all for Salesforce, IndexedDB. At each step, we’ll measure what changed and why it matters. You can find the full code here: [sandriiy/tnd26-lwc-opportunity-radar: An Opportunity Radar experience built for True North Dreamin 2026 in Toronto](https://github.com/sandriiy/tnd26-lwc-opportunity-radar/tree/problem?ref=bulkifiedthinking.com) ## What’s the problem with Apex-only implementations? The best way to start this journey is to ask yourself what’s the problem with using only Apex controllers and building Lightning Web Components like this: ```apex import getOpportunities from '@salesforce/apex/OpportunityRadarController.getOpportunities'; import createFollowUpTask from '@salesforce/apex/OpportunityRadarController.createFollowUpTask'; import updateNextStep from '@salesforce/apex/OpportunityRadarController.updateNextStep'; import updateCloseDate from '@salesforce/apex/OpportunityRadarController.updateCloseDate'; import updateStage from '@salesforce/apex/OpportunityRadarController.updateStage'; export default class OpportunityRadar extends LightningElement { // ... async handleCreateFollowUpTask(event) { await createFollowUpTask({ opportunityId, subject, dueDate }); await this.loadOpportunities(); } async handleUpdateNextStep(event) { await updateNextStep({ opportunityId, nextStep }); await this.loadOpportunities(); } async handleUpdateCloseDate(event) { await updateCloseDate({ opportunityId, closeDate }); await this.loadOpportunities(); } // ... } ``` It is clear. It is simple. It works. But that does not mean it is good. The problem is that this kind of implementation pushes almost every responsibility to the server. Every click, every search, every scroll, every refresh becomes another server request. The browser waits, the network adds latency, and the user gets a spinner… again. Analyzing solutions like this, I never cease to be amazed by the amount of red in [DevTools](https://developer.chrome.com/docs/devtools?ref=bulkifiedthinking.com). ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-1.png) DevTools, Chrome for Developers **27 seconds** to render the main content and **3 seconds** to respond after a user interaction… With delays like that, even a “headless” might start to look like a good idea. And this is only the visible part of the problem. Other reasons include: - **Over-fetching,** returning “everything just in case” makes the network payload bigger, slows down rendering, and wastes time on data the user may never see. - **Security,** when everything goes through Apex, you are responsible for enforcing sharing, CRUD, and field-level security. Miss one check, and the UI can expose data the user was never meant to see. - **Large datasets,** loading too much data in a wrong way makes the UI slower with every new record, until the component eventually becomes unusable. - **Offline-friendly patterns**, if the browser stores nothing and reuses nothing, even a small network issue turns the whole experience into a waiting game. ## Use Lightning Data Service (LDS) This is the easiest way to improve performance and add reactive behavior to your components. It allows you to stop writing Apex for basic record operations and hand it off to Salesforce, which will also be able to automatically cache and update the record across multiple components. For reference, take this line of code from an **Apex-only implementation**: ```javascript import updateNextStep from '@salesforce/apex/OpportunityRadarController.updateNextStep'; async handleUpdateNextStep(event) { const { opportunityId, nextStep } = event.detail; await updateNextStep({ opportunityId, nextStep }); await this.loadOpportunities(); // full reload } ``` Looking at this function, two obvious questions appear: - Why do I need to call `this.loadOpportunities` every time to update the view? - Do I even need a custom Apex method named `updateNextStep` here? **The first question is correct.** If a user updates a record in the UI and you update it in the database, why do you need to retrieve the same record from the database again? **The second question is not stupid either.** If Salesforce gives you LDS to update a record, why are you writing custom Apex for the same thing? 📌 **Okay, so how do we actually call LDS?** Well…. We don’t 🙂 Not directly. LDS is not a function you call like this: `LDS.updateRecord()`. It works behind the scenes, and Salesforce gives you components and modules built on top of it. In this case, the module we need is `lightning/uiRecordApi`, because it provides the `updateRecord` function. So instead of calling custom Apex and then reloading the whole list, the code now look like this: ```javascript // Import lightning/ui*Api Built on LDS import { updateRecord } from 'lightning/uiRecordApi'; async handleUpdateNextStep(event) { const { opportunityId, nextStep } = event.detail; await updateRecord({ fields: { Id: opportunityId, [NEXTSTEP_FIELD.fieldApiName]: nextStep } }); this.patchOpportunity(opportunityId, { nextStep }); } patchOpportunity(id, fieldDelta) { this.allOpportunities = this.allOpportunities.map(opp => { if (opp.id !== id) return opp; return enrichWithRisk({ ...opp, ...fieldDelta }); }); } ``` Behind the scenes, a lot of optimization and processing happens during an `updateRecord` call, including: - **Reactive behavior,** Salesforce will notify other components that use LDS for the same record, so they can receive the updated data without you manually doing anything. - **Client-side caching,** Salesforce keeps track of records in the LDS cache and reuses as much data as possible instead of going back to the server every time. - **Security handling** , LDS respects platform access rules like CRUD, FLS, and sharing. This brings us to the numbers below. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-2.png) DevTools, Chrome for Developers The metric we care about the most here is **INP**, which measures how quickly the page responds after a user interaction. LCP and CLS in Salesforce can fluctuate because of the platform, page layout, standard components, network conditions, and other things outside of our custom LWC. ## State Management Now let’s move on to a new capability that is generally available in Summer ’26, [State Management](https://developer.salesforce.com/docs/platform/lwc/guide/state-management.html?ref=bulkifiedthinking.com). This is a centralized place to manage all data and changes across your Lightning Web Components. It extends across the entire component tree (parent → child → child and so on) and gives you precise control over which dependencies update and which don’t. At first glance, this does not look like a performance improvement tool. It looks more like **a better way** to control the data management process. And that is true. State Manager is designed exactly for that: managing state in a cleaner, more predictable way. That being said, once your state is centralized and your data flow is easier to understand, it becomes much easier to spot performance problems. In our [Apex-only implementation](https://github.com/sandriiy/tnd26-lwc-opportunity-radar/tree/problem?ref=bulkifiedthinking.com), after switching to State Manager, I noticed this getter: ```javascript get visibleOpportunities() { const paginated = this.filteredOpportunities.slice(0, this.currentPage * this.filters.pageSize); return paginated.map(opp => ({ ...opp, isSelected: opp.id === this.selectedOpportunityId })); } ``` What happens here is that when a single opportunity changes, this getter executes, creates **a completely new** list of objects, and forces every card on the screen to re-render. Every single one. State Manager lets you spot those problems. It lives as a separate utility component structured like this: ```javascript import { defineState } from '@lwc/state'; export default defineState(({ atom, computed, setAtom }) => { // ... }); ``` We use the newly introduced `defineState` function to define the values and actions our state manager exposes. Inside it, we declare every piece of state we want to control. For our implementation it looks like this: ```javascript const allOpportunities = atom([]); const snoozedIds = atom([]); const selectedId = atom(null); const filters = atom({ ...DEFAULT_FILTERS }); const isLoading = atom(false); const hasError = atom(false); const errorMessage = atom(''); const lastRefresh = atom(null); const currentPage = atom(1); ``` An `atom` represents a single, reactive piece of data. Unlike a plain variable, an atom is a wrapper that plugs the value into the reactive graph, which is exactly what lets the runtime build a precise map of what depends on what, instead of blindly invalidating everything. Then `computed` comes in: ```javascript const visibleOpportunities = computed( [filteredOpportunities, currentPage, filters, selectedId], (filtered, page, f, sid) => filtered.slice(0, page * f.pageSize).map(opp => ({ ...opp, isSelected: opp.id === sid })) ); ``` Here we declare that `visibleOpportunities` depends on four values (`filteredOpportunities`, `currentPage`, `filters`, `selectedId`). If any of them change, it recomputes. If none of them change, it doesn’t, regardless of what else happens in the application. When we need to update an opportunity, we do it like this: ```javascript const patchOpportunity = (id, fieldDelta) => { setAtom( allOpportunities, allOpportunities.value.map(opp => opp.id !== id ? opp : enrichWithRisk({ ...opp, ...fieldDelta }) ) ); }; ``` `setAtom` is the only way to write to an atom, direct assignment is not allowed. Every mutation must go through `setAtom`, which is what allows the runtime to intercept the change, walk the dependency graph, and determine the exact minimum set of things that need to update. The most important part is that, **in this version**, I am not recreating the entire list of opportunities like I did before. Instead, I update only the opportunity that actually changed. **That kind of problem becomes much easier to spot when your state logic is centralized in one place.** To counterbalance this once more, this spread syntax (`...`) was literally creating a new object for every opportunity and putting all of them into a new list. ```javascript allOpportunities.value.map(opp => ({ ...opp, isSelected: opp.id === selectedId })) ``` So, we added a state manager. **How much did it improve performance in this case?** ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-3.png) DevTools, Chrome for Developers An incredible **553 milliseconds of INP**. Compared to the previous value of **2676 milliseconds**, the interface now responds to user actions **4.84× faster than before**. If that’s not an improvement, I don’t know what is. ## **Browser Persistence Layer** Up until now, we have been optimizing the UI starting from the moment all data has been received from Apex. **What if we could optimize the initialization process as well?** At the moment, the load function looks like this: ```javascript const loadFeed = async () => { setAtom(isLoading, true); setAtom(hasError, false); setAtom(errorMessage, ''); try { const raw = await getOpportunities(); setAtom(allOpportunities, raw.map(opp => enrichWithRisk({ ...opp, isSelected: false }))); setAtom(lastRefresh, new Date().toLocaleTimeString()); setAtom(currentPage, 1); } catch (error) { showError(error); } finally { setAtom(isLoading, false); } }; ``` We get the list of opportunities from Apex and pass them to the state manager for storage. And here I ask myself: **What if the data hasn’t changed since the last time the user opened this page?** Why do we have to reload all the records every time? Why can’t we store them in the user’s browser? If you’re at all familiar with browser storage, you’ve probably come across [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage?ref=bulkifiedthinking.com) and [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage?ref=bulkifiedthinking.com). These are your go-to options for storing data on the client, but they’re typically used for lightweight stuff, things like active filters or language preferences, not for large amounts of data. For large amounts of data, [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB%5FAPI?ref=bulkifiedthinking.com) is the right tool. It’s a NoSQL database built directly into modern web browsers. It can handle large amounts of data and comes with features like indexes and cursors. In our case, we’ll create a new utility component `opportunityRadarCache` that will be responsible for opening, reading from, and writing to [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB%5FAPI?ref=bulkifiedthinking.com): ```javascript const DB_NAME = 'opportunityRadar'; const DB_VERSION = 1; const STORE_NAME = 'opportunities'; function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); // open or create a database request.onupgradeneeded = (event) => {}; // goes here is it's being created request.onsuccess = (event) => resolve(event.target.result); // goes here if it's being opened }); } async function readOpportunities() { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); const request = tx.objectStore(STORE_NAME).getAll(); // read data }); } async function writeOpportunities(records) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); store.clear(); records.forEach(record => store.put(record)); // store data }); } ``` Now that we have [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB%5FAPI?ref=bulkifiedthinking.com) set up, with an [object store](https://developer.mozilla.org/en-US/docs/Web/API/IDBObjectStore?ref=bulkifiedthinking.com) called `opportunities` to hold our records, and two functions to read and write data, we can update our `loadFeed` function like this: ```javascript const loadFeed = async () => { setAtom(hasError, false); setAtom(errorMessage, ''); setAtom(currentPage, 1); let hasCachedData = false; try { const cached = await readOpportunities(); if (cached.length > 0) { setAtom(allOpportunities, cached); // render immediately from cache hasCachedData = true; } } catch (e) {} if (!hasCachedData) { setAtom(isLoading, true); // spinner only when there is nothing to show } try { const raw = await getOpportunities(); const enriched = raw.map(opp => enrichWithRisk({ ...opp, isSelected: false })); setAtom(allOpportunities, enriched); setAtom(lastRefresh, new Date().toLocaleTimeString()); try { await writeOpportunities(enriched); markSynced(); } catch (e) {} } catch (error) { const statusCode = error?.body?.statusCode; if (statusCode === 401 || statusCode === 403) { try { await clearCache(); } catch (e) {} } if (!hasCachedData) { showError(error); } } finally { setAtom(isLoading, false); } }; ``` I’ve introduced [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB%5FAPI?ref=bulkifiedthinking.com) into the process. Since it’s a persistent store, it can live in the user’s browser indefinitely. That’s why we still call Apex to refresh the data, but that call now happens in the background. The user gets their first render in \~50ms from [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB%5FAPI?ref=bulkifiedthinking.com), and Apex runs silently behind the scenes to update any records that have changed. This is where state management earns its place a second time. Because our reactive graph only re-renders what actually changed, swapping in the fresh Apex data doesn’t re-render the entire HTML, only the records that differ get updated. **That’s awesome.** On that note, let’s come back to [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage?ref=bulkifiedthinking.com) and [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage?ref=bulkifiedthinking.com), I used both of them to improve the user experience as well. Their fundamental difference is: - [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage?ref=bulkifiedthinking.com) is a small persistent storage in the browser. It keeps its values even after you close the browser or reboot your machine. - [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage?ref=bulkifiedthinking.com) is a small temporary storage. It keeps its values until you close the tab. Some browsers restore tabs after a reboot, so it may technically survive one, but you shouldn’t rely on it. In our case, I introduce both into the state manager to restore context when the page loads. This doesn’t improve raw performance, but it makes the app feel like it remembers and cares about the user: ```javascript // storage keys, namespaced and versioned to avoid collisions const PREF_KEYS = { riskFilter: 'opportunityRadar:v1:riskFilter', compactView: 'opportunityRadar:v1:compactView' }; const SESSION_KEYS = { selectedId: 'opportunityRadar:v1:selectedOpportunityId', snoozedIds: 'opportunityRadar:v1:snoozedIds' }; // read once at module init — before any atom is created function readPreferences() { const riskFilter = localStorage.getItem(PREF_KEYS.riskFilter); const compactView = localStorage.getItem(PREF_KEYS.compactView); const saved = {}; saved.riskFilter = riskFilter; saved.compactView = compactView === 'true'; return saved; } function readSession() { const sid = sessionStorage.getItem(SESSION_KEYS.selectedId); const snoozedRaw = sessionStorage.getItem(SESSION_KEYS.snoozedIds); return { selectedId: sid || null, snoozedIds: snoozedRaw ? JSON.parse(snoozedRaw) : [] }; } export default defineState(({ atom, computed, setAtom }) => { const savedPrefs = readPreferences(); // from localStorage const savedSession = readSession(); // from sessionStorage // set detault values if any }); ``` And finally, let’s take a look at the INP metric and see how we’re doing: ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-4.png) DevTools, Chrome for Developers Our user responsiveness improved by another **167 milliseconds**. And although this does not look as dramatic as the improvement we got with the state manager, look at the **LCP** metric. Yes, I told you not to focus too much on it. LCP shows how long it takes for the largest visible content element to render when the page loads, and in Salesforce, this metric is affected by too much platform stuff. That is still true. But in this case, the improvement is too big to ignore. Whatever we changed definitely contributed to this number, because the initial page load is now much more efficient. And this is the **first time we get green somewhere.** ## **Apex Cursor for Deep Pipeline Scan** And now we only have to take care of one thing: **data transfer from Apex to the Lightning Web Component.** At this point, we have optimized the Lightning Web Component to its maximum capacity, including the initialization process, loading previously cached data and synchronizing it with the database in the background. But there is still one aspect that has not been addressed: Apex transfers all opportunity records to the Lightning Web Component **at once**, and there we add them to IndexedDB and implement pagination on the client side: ```apex // Query all at once private List queryOpportunities() { Id currentUserId = UserInfo.getUserId(); String baseQuery = 'SELECT Id, Name, Account.Name, StageName, Amount, CloseDate, Owner.Name, ' + 'NextStep, LastActivityDate, LastModifiedDate, Probability, ForecastCategoryName ' + 'FROM Opportunity WHERE IsClosed = false' + ' AND OwnerId = :currentUserId' + ' ORDER BY CloseDate ASC NULLS LAST LIMIT 50000'; return Database.query(baseQuery); } ``` ```javascript // Write to IndexedDB async function writeOpportunities(records) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); store.clear(); records.forEach(record => store.put(record)); tx.oncomplete = () => resolve(); }); } ``` And as you can see, even such an approach can work. We were able to optimize the Lightning Web Component quite significantly given that this code was in place the whole time. **But this is still not the approach I’d recommend.** We need to implement pagination between Apex and the Lightning Web Component, where Apex transfers data in chunks, and if the user asks for more, we fetch the next chunk from Apex. This is made possible, without any `OFFSET` workarounds, thanks to the [Pagination Cursor in Apex](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex%5Fcursors.htm?ref=bulkifiedthinking.com), which has been generally available since Spring '26. The way it works: you create a cursor by passing your SOQL query as a parameter, like this: ```apex Database.PaginationCursor pagCursor = Database.getPaginationCursor('SELECT Id, Name FROM Account'); ``` This creation step **does not fetch any records yet**, it creates a snapshot of the query result set that you can page through across multiple transactions. Later, when you need to fetch records, you call: ```apex Database.CursorFetchResult page = pagCursor.fetchPage(0, 200); ``` The advantages of this approach are: first, you can fetch **up to 100,000 records**, which is double the limit of a standard SOQL query. Second, you can retrieve records **in any range by offset**, and you avoid all sort of problems that come with `OFFSET` in large datasets. In our case, we replace this: ```apex private List queryOpportunities() { Id currentUserId = UserInfo.getUserId(); String baseQuery = 'SELECT Id, Name, Account.Name, StageName, Amount, CloseDate, Owner.Name, ' + 'NextStep, LastActivityDate, LastModifiedDate, Probability, ForecastCategoryName ' + 'FROM Opportunity WHERE IsClosed = false' + ' AND OwnerId = :currentUserId' + ' ORDER BY CloseDate ASC NULLS LAST LIMIT 50000'; return Database.query(baseQuery); } ``` With this, using the [Pagination Cursor](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex%5Fcursors.htm?ref=bulkifiedthinking.com): ```apex public CursorPage fetchOpportunitiesPage(String cursorToken, Integer batchSize) { // ... // Either resume an existing cursor or open a new one. if (String.isNotBlank(cursorToken)) { CursorToken token = (CursorToken) JSON.deserialize(cursorToken, CursorToken.class); pagCursor = (Database.PaginationCursor) JSON.deserialize(token.cursorState, Database.PaginationCursor.class); } else { pagCursor = Database.getPaginationCursor( 'SELECT Id, Name, Account.Name, StageName, Amount, CloseDate, Owner.Name, ' + 'NextStep, LastActivityDate, LastModifiedDate, Probability, ForecastCategoryName ' + 'FROM Opportunity WHERE IsClosed = false' + ' AND OwnerId = :currentUserId' + ' ORDER BY CloseDate ASC NULLS LAST' ); } // Fetch the current page by offset Database.CursorFetchResult fetchResult = pagCursor.fetchPage(startIndex, size); List opportunities = new List(); for (SObject s : fetchResult.getRecords()) { opportunities.add((Opportunity) s); } // ... } ``` **An important property of the Pagination Cursor is that it is serializable.** Rather than opening a new cursor on every call, we open it once, serialize it with `JSON.serialize()`, and send the result to the client alongside the records. The client stores it and passes it back with each subsequent request, allowing us to resume from the exact same place. The one nuance when combining cursor-based pagination with IndexedDB is state management. You need to be deliberate about how records are written to ensure the user always sees consistent data and stale records are eventually removed. In our implementation, I hope it’s handled cleanly… Here are the final results: ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-5.png) DevTools, Chrome for Developers We moved INP from **3,034ms to 347ms,** the UI became around **87% more responsive after user interaction**. LCP also dropped from **27.34s to 0.40s**, which, while not the most consistent metric in a Salesforce context, is still an improvement we contributed to. ## Lightning Web Security & Lightning Locker And I would like to end with a note about security and its effect on performance. As you might have guessed, many of the capabilities shown today, especially modern browser capabilities such as IndexedDB, depend on [Lightning Web Security](https://developer.salesforce.com/docs/platform/lightning-components-security/guide/lws-intro.html?ref=bulkifiedthinking.com) being enabled. ![](https://storage.ghost.io/c/92/d5/92d55211-2cfe-45c9-b8c4-b7c7b1595cdb/content/images/2026/07/image-6.png) [Enable LWS in an Org](https://developer.salesforce.com/docs/platform/lightning-components-security/guide/lws-enable.html?ref=bulkifiedthinking.com) If Lightning Web Security is disabled, Salesforce falls back to the older Lightning Locker framework. Locker still provides security isolation, but it is more restrictive and can block or wrap access to some modern browser APIs. *If you are interested in a deeper look at these frameworks and how they work internally,* [***check out this video***](https://youtu.be/V-e%5FnAfiNvU?ref=bulkifiedthinking.com) *where I explain the architecture in more detail.* For those who already understand what I am talking about, let me leave you with a few final tips. - When working with Lightning Data Service or Apex, and especially when dealing with large amounts of data, consider cloning the returned data into your component sandbox before doing heavy client-side processing. This helps avoid repeatedly accessing host-owned data through JavaScript Proxy boundaries. ```javascript // Move data into the component sandbox. const localData = JSON.parse(JSON.stringify(data)); // Now process local plain objects. this.rows = localData.map(row => ({ id: row.Id, name: row.Name, amount: row.Amount, stageName: row.StageName, closeDate: row.CloseDate, riskScore: this.calculateRisk(row) })); ``` This pattern is not something you should blindly apply everywhere. For small datasets, it usually does not matter. But when you process, filter, sort, or transform large LDS or Apex responses on the client side, **it can make a noticeable difference.** - When using IndexedDB or other modern browser APIs, make sure Lightning Web Security is enabled. Under Lightning Locker, some browser APIs will be restricted or wrapped, so the same code might not work as expected. That’s all for today. Don’t forget to follow for new updates, and contact me on [**LinkedIn**](https://www.linkedin.com/in/andrii-sukhetskyi-305939201/?ref=bulkifiedthinking.com) if you have any questions, suggestions, or ideas to share. Leave your questions in the comments. I’m happy to answer. ### Use Third-Party JavaScript Code With Lightning Web Components URL: https://www.bulkifiedthinking.com/use-third-party-javascript-code-with-lightning-web-components/ Last updated: 2026-08-16T23:04:09.000Z Just to make things clear, Salesforce provides an excellent guides titled [Use Third-Party JavaScript Libraries](https://developer.salesforce.com/docs/platform/lwc/guide/js-third-party-library.html?ref=bulkifiedthinking.com) and [Use Third-Party Web Components in LWC](https://developer.salesforce.com/docs/platform/lwc/guide/create-use-third-party-components.html?ref=bulkifiedthinking.com), which covers everything thoroughly. In 80% of cases, this information is enough to get started, but if you’re in those 20% who can’t get it to work — let’s dig in, my fellow Salesforce Developers! The information provided was compiled during the [Lightning Web Development workshop](https://www.youtube.com/playlist?list=PLTyqynuY2hfljsP4KcY7KgT2X6bC0vU0%5F&ref=bulkifiedthinking.com) and offers detailed guidance on how to get third-party JavaScript code working with your Salesforce org. --- ## Types of Third-Party JavaScript Code To begin, let’s distinguish clearly between the two types of JavaScript code that you can use with your Lightning Web Components. ### **Third-Party Web Components** This is a JavaScript file that defines a class extending the native HTMLElement class. This class is responsible for creating a custom HTML element and provides a visible User Interface (UI) element. It could be something familiar, like a datatable, developed outside of Salesforce. You upload it as a static resource and use it within your LWC. This same datatable might include features like lazy-loading and corresponding data handling. See an example below: ```javascript class MyCustomElement extends HTMLElement { constructor() { // Always call super first in constructor super(); // Create a shadow root this.shadowRoot = this.attachShadow({ mode: "open" }); } connectedCallback() { // Element is added this.shadowRoot.innerHTML = "
"; } // ... } // Registers the element customElements.define("my-custom-element", MyCustomElement); ``` ### **Third-Party JavaScript Libraries** This is a JavaScript file that provides reusable functions or objects. It’s not designed to represent visual elements directly, like HTML tags. Instead, it’s more like a “utils.js”, you call its functions when you need them. Some libraries may create visual output, like charts or animations, but they do so programmatically and don’t define custom HTML elements. For instance: ```javascript (function (global) { function Chart(ctx, config) { // Very simplified chart renderer ctx.fillStyle = config.data.datasets[0].backgroundColor[0]; ctx.fillRect(10, 10, 100, 100); } global.Chart = Chart; })(window); ``` ## Why Isn’t My Third-Party Web Component Working? > Lightning Web Security (LWS) must be enabled in the Salesforce org because Lightning Locker doesn’t support third-party web components. Let’s say you found the perfect component on [webcomponents.org](https://www.webcomponents.org/?ref=bulkifiedthinking.com), added it to your static resources, imported it into your Lightning Web Component using [loadScript](https://developer.salesforce.com/docs/component-library/bundle/lightning-platform-resource-loader/documentation?ref=bulkifiedthinking.com), and used the lwc:external attribute in your markup to indicate that it’s an external web component. And yet, it doesn’t work as expected. ```javascript import { LightningElement } from 'lwc'; import yourstaticresource from '@salesforce/resourceUrl/webcomponent'; import { loadScript } from 'lightning/platformResourceLoader'; export default class MyComponent extends LightningElement { componentLoaded = false; renderedCallback() { if (!this.componentLoaded) { this.componentLoaded = true; loadScript(this, yourstaticresource) .then(() => { console.log('Custom web component loaded'); }) .catch(error => { console.error('Failed to load web component:', error); }); } } } ``` ```javascript ``` In most cases, you’ll need to modify the code of the web component itself to make it work. So, let’s look at the most common problems: ### Open Shadow DOM You’ve probably heard that one of the “favorite things” among Salesforce Developers is the [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web%5Fcomponents/Using%5Fshadow%5FDOM?ref=bulkifiedthinking.com). This Shadow DOM can operate in two modes: *open* and *closed.* Now, here’s the crucial part, your 3rd-party web component **CANNOT** use the open Shadow DOM, and if it does — expect it to behave incorrectly in Salesforce. But what does that actually mean? An open Shadow DOM allows external sources to access the component’s shadow tree via the `shadowRoot` property on the element. In contrast, a closed Shadow DOM prevents such direct access, keeping the internal structure fully encapsulated. Salesforce enforces the use of the closed shadow mode. To ensure compatibility, open the source code of your Web Component and locate the following line: ```javascript class MyCustomElement extends HTMLElement { constructor() { // ... // Create an open shadow root this.shadowRoot = this.attachShadow({ mode: "open" }); } // ... ``` Your task is to replace the `mode: "open”` attribute with `mode: "closed”` Next, identify all occurrences of the `shadowRoot` property in the code, including the initial assignment line, and replace them with `_shadow` (or any other custom name, but not `shadowRoot`) as demonstrated below: ```javascript ❌ this.shadowRoot = this.attachShadow({ mode: 'open' }); ❌ this.shadowRoot.innerHTML = `

Hello from shadow DOM

`; ✅ this._shadow = this.attachShadow({ mode: 'closed' }); ✅ this._shadow.innerHTML = `

Hello from CLOSED shadow DOM

`; ``` ### Global Access Is Restricted If a 3rd-party web component uses the global `document` object to retrieve an existing element by ID (note: creating a new element is acceptable), this can and will lead to unexpected behavior. To resolve this issue, open the source code of the web component and locate all instances where an element is queried by ID. For example: ```javascript document.getElementById('some-id'); ``` There are many possible approaches here, but one of the simplest, and my preferred approach, is to use the component’s Shadow DOM instead of accessing the global document object. For instance: ```javascript class MyCustomElement extends HTMLElement { constructor() { this._shadow = this.attachShadow({ mode: 'closed' }); } someFunction() { ❌ document.getElementById('some-id'); ✅ this._shadow.getElementById('some-id'); } // ... ``` ### Dependencies or Modules If you’ve reached this point and your web component still doesn’t work, the most likely issue is that the component uses or relies on external dependencies or ES modules. **What does this mean?** For example, suppose your Web Component includes the following line of code: ``` ``` What’s actually happening here is that the Web Component is attempting to load a JavaScript module directly from an external source, a resource that hasn’t been uploaded as a static resource in your Salesforce org. The rule is simple: **only scripts that have been added as static resources and loaded using `loadScript()`** can be used in Lightning Web Components (LWC). Anything else, such as external `