How Big Is Too Big? Downloading Large Files From an External System Through an LWC

Reading Time: 29 min
Author: William Watson Published: October 13, 2026 System: An LWC calling an external system through Apex Scale: Files of kilobytes to hundreds of megabytes Failure: Heap, callout size and concurrency limits Audience: Salesforce architects and developers
Current: Top of article
Direct answer

Keep the Apex controller pattern for files under about 4 MB, because the 6 MB synchronous callout response limit and the heap cost of base64 put the ceiling, by the sums in the post, at about 4 MB. Beyond that, have Apex fetch a short-lived signed URL and let the browser download directly.

A team asked me recently how a Lightning web component should let users download reports that live in another system. The files never live in Salesforce. Only an integration user can reach the external reporting system, so the component cannot simply link to it. The first instinct, and the first thing everyone builds, is an Apex controller: the component calls Apex, Apex calls the external system, and the bytes come back through the browser.

That works for small files and falls over for big ones. The interesting part is where the line sits and why. Nobody in the conversation could say what Salesforce counts as a small or a large file, because Salesforce never says. The limits say it for you, and one of them changed this release. This article quotes the current limits, does the arithmetic, and ends with the pattern I recommend once a file is more than a few megabytes.

Every figure below is quoted from the pages in the references, as they read for Winter ‘27, or is a sum worked from them and marked as reasoning. I have not measured these ceilings in an org.

Which limits decide how big a file an LWC can pull through Apex?

Three limits from the Apex Developer Guide’s Execution Governors and Limits page: the heap size, the callout request and response size, and the number of concurrent long-running transactions. There is no row for “maximum file size”, and no page that says what counts as small or large, so the ceiling falls out of those three.

Heap. The number most people remember is 6 MB for synchronous Apex and 12 MB for asynchronous Apex. That number is now wrong. The Winter ‘27 release note “Handle Larger Datasets with Increased Apex Heap Limits” says the synchronous limit increases from 6 MB to 10 MB and the asynchronous limit from 12 MB to 25 MB. The increase is enabled automatically in all orgs on the Winter ‘27 release schedule, and the Per-Transaction Apex Limits table now reads 10 MB and 25 MB. The note says to confirm your org’s figure with Limits.getLimitHeapSize(). In a sandbox, Developer Edition or scratch org, Apex Settings has a checkbox named “Enforce the Summer ’26 Apex heap limit”. The note says it is available only in Winter ‘27 nonproduction orgs, and that Spring ‘27 enforces the higher limits everywhere. If a post you are reading quotes 6 and 12, it predates this release.

Callout request and response size. The Static Apex Limits table puts the maximum size of a callout request or response at 6 MB for synchronous Apex or 12 MB for asynchronous Apex. This row did not move. A file fetched from an external system arrives as a callout response, so in the Apex controller pattern this is the binding limit, not the heap. The heap went up; the biggest response Apex can receive did not. The row’s footnote adds that the request and response sizes count towards the total heap size, which matters for the sums below.

Concurrent long-running transactions. The Salesforce Platform Apex Limits table caps the number of synchronous concurrent transactions that last longer than 5 seconds per org. The cap is not the flat 10 that older posts quote. It is a ratio of 100 applicable licences to one concurrent long-running transaction, with a minimum of 10 and a maximum of 50. Its footnote says that transactions started while that many are still running are denied, and that HTTP callout processing time is not included. It is the limit people misquote, and the concurrency section below works out what it means for a download.

Those three limits are the whole story on the Salesforce side. Read them together and the answer to “how big is too big” has three parts: a hard stop, a practical ceiling below it, and a concurrency limit to check.

How big a file can an Apex controller actually hand to the component?

A single file of about 4 MB on disk is the practical ceiling by the sums below, and 6 MB is the hard stop. Here is the pattern first, then the sums.

The controller makes the callout, reads the body as a Blob, base64-encodes it and returns the string. The component decodes nothing. It builds a data URL from the string and hands it to an anchor with a download attribute.

// Written against API version 68.0 (Winter '27)
public with sharing class ReportDownloadController {

    @AuraEnabled(cacheable=false)
    public static String fetchReport(String reportId) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Reporting_System/reports/' + reportId);
        req.setMethod('GET');
        req.setTimeout(120000);

        HttpResponse res = new Http().send(req);
        if (res.getStatusCode() != 200) {
            throw new AuraHandledException('Download failed: ' + res.getStatus());
        }

        Blob body = res.getBodyAsBlob();
        return EncodingUtil.base64Encode(body);
    }
}
// reportDownload.js
import { LightningElement, api } from 'lwc';
import fetchReport from '@salesforce/apex/ReportDownloadController.fetchReport';

export default class ReportDownload extends LightningElement {
    @api reportId;
    @api fileName = 'report.pdf';

    async handleDownload() {
        const base64 = await fetchReport({ reportId: this.reportId });
        const link = document.createElement('a');
        link.href = 'data:application/octet-stream;base64,' + base64;
        link.download = this.fileName;
        link.click();
    }
}

Now the sums. They are reasoning from the limits, not a measurement in an org.

Binary cannot travel from Apex to the component as bytes. It is base64-encoded, and base64 represents every 3 bytes as 4 characters, so the encoded string is about a third larger than the file. A 6 MB response becomes an 8 MB string. The limits page says the response size counts towards the heap, and in the controller above the encoded String is live alongside the response Blob at the point of return. So a 6 MB file needs about 14 MB of heap, above the new 10 MB limit, and the page’s footnote says a transaction that exceeds the heap gets an uncatchable System.LimitException. The biggest callout response Apex can receive is not a file this controller can return.

Work it backwards. With both copies live, a file and its string together take two and a third times the file size, so 10 MB of heap holds a file of about 4 MB. If the runtime has released the Blob by the time the string is built, only the 8 MB string has to fit, and the 6 MB callout response becomes the hard stop. The limits page does not say which of those the runtime does, so I design to about 4 MB. A 5 MB file fits only if the runtime has released the Blob before the string is built, and the page does not say whether it does, so test it in a sandbox before promising it.

Before Winter ‘27 the same sums against a 6 MB heap gave a practical ceiling of about 2.5 MB on disk with both copies live. A 3 MB file is a 4 MB string, and the two together are already 7 MB, so even 3 MB depended on the Blob being gone. The heap increase is real and it helps. It moves the practical ceiling by about a megabyte and a half. It does not move the 6 MB callout response limit, and it does not make “large” files possible through this pattern.

One more thing keeps the ceiling lower in practice. Any wrapper you build around the string in the controller, such as an object holding the file name and the content, is another copy on the heap. Treat about 4 MB as the number you design to, and 5 MB as the number you test.

Why don’t the asynchronous limits help?

They do not, because an asynchronous job cannot return anything to the browser. The 25 MB heap and the 12 MB callout response belong to asynchronous Apex, which the limits page names as Batch Apex and future methods, and the user who clicked the button is not there when those run.

The only way to use the asynchronous figures is to fetch the file in a job and park it in Salesforce as a ContentVersion the component then links to. For a file that is allowed to live in Salesforce that is a reasonable design. For the problem in this article it is the wrong answer. The business asked for a pass-through precisely because the files must not live here. Parking a copy in ContentVersion to get around a limit creates a second system of record, a retention question and a cleanup job, and it still caps the file at 12 MB.

What happens when several users download at once?

Less than you might fear, by the documented count, because the limits page leaves HTTP callout processing time out of the 5 seconds. The slot is at risk only when Apex itself spends more than 5 seconds on the response, which is reasoning from the footnote rather than a measurement.

Start with the row as it reads. The number of synchronous concurrent transactions that last longer than 5 seconds is calculated as a ratio of 100 licences to one concurrent long-running Apex transaction, with a minimum of 10 and a maximum of 50. The page’s example: an org with 4,000 licences gets 40, an org with 5,000 or more gets the cap of 50, and an org with 1,000 or fewer gets the floor of 10. The footnote on that row says: “If more transactions are started while the default number of long-running transactions are still running, they’re denied. HTTP callout processing time isn’t included when calculating this limit.”

Follow that through for the controller above. The seconds a user spends waiting are mostly the external system producing the file and streaming it back, and that is callout processing time, which the footnote excludes. What counts is Apex’s own work on the response: reading a multi-megabyte body, base64-encoding it and building the return value. Whether that work passes 5 seconds for a file near the 4 MB ceiling is not something the page can tell you. Measure it on your biggest file with a debug log before you plan around it. The callout failures post linked below records a practitioner report of synchronous callout time counting in practice, confirmed by Salesforce support, so measure rather than assume.

If Apex’s own time does pass 5 seconds, the download holds one of the org’s slots for that time. In an org at the floor, ten such downloads at once fill the slots, and the next long-running transaction of any kind is denied: a Visualforce controller, another component’s Apex, a REST resource. The message a denied request gets, and where it shows up in the logs, is in the callout failures post.

By the documented count, the signed URL pattern in the next section never comes near the line. Its Apex does a few hundred characters of work, and the wait, if any, is callout time.

What is the right pattern beyond a few megabytes?

Apex asks the external system for a short-lived signed download URL, the component opens that URL, and the browser streams the file straight from the source. Salesforce never touches the bytes, so there is no size limit on our side.

The callout payload is tiny: a request for a link and a response holding a URL and an expiry. The heap carries a string of a few hundred characters. The file can be a few megabytes or a few gigabytes and the Salesforce side of the design does not change.

// Written against API version 68.0 (Winter '27)
public with sharing class ReportLinkController {

    public class DownloadLink {
        @AuraEnabled public String url;
        @AuraEnabled public Integer expiresInSeconds;
    }

    @AuraEnabled(cacheable=false)
    public static DownloadLink requestLink(String reportId) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Reporting_System/reports/' + reportId + '/download-link');
        req.setMethod('POST');
        req.setTimeout(10000);

        HttpResponse res = new Http().send(req);
        if (res.getStatusCode() != 200) {
            throw new AuraHandledException('Could not get a download link: ' + res.getStatus());
        }

        return (DownloadLink) JSON.deserialize(res.getBody(), DownloadLink.class);
    }
}
// reportLink.js
import { LightningElement, api } from 'lwc';
import requestLink from '@salesforce/apex/ReportLinkController.requestLink';

export default class ReportLink extends LightningElement {
    @api reportId;

    async handleDownload() {
        const link = await requestLink({ reportId: this.reportId });
        window.open(link.url, '_blank', 'noopener');
    }
}

The named credential still does its job, on the server. The integration user’s credentials never leave Apex. The only thing that leaves the server is a URL that works once, or for a few minutes, for one file. Ask the external system for the shortest expiry that survives a slow click, and ask it to tie the URL to the file rather than to a session.

The component opens the URL with window.open, or by setting it on an anchor. It does not fetch it. That distinction matters for the next section.

What do I need to confirm with the external system?

The pattern moves the work to the other side of the integration, so the questions move there too. These are the four I ask before I commit to it.

Checklist: confirm these with the team that owns the external system before you build the signed URL pattern.

  1. It can issue signed or tokenised download URLs. Object stores and most document stores can. An older reporting system may only serve files to an authenticated session. If it cannot, you are back to the Apex controller and its 4 MB ceiling, or to a small service in front of the store that can.
  2. It is reachable from users’ browsers, not only server to server. An endpoint that only accepts calls from Salesforce’s IP ranges, or that sits inside a network the users are not on, cannot serve a browser. Check this from a real user’s laptop, not from the integration host.
  3. The credential stays in Apex behind a named credential, and the signed URL is the only thing that reaches the browser. The Named Credentials help page describes a named credential as the URL of a callout endpoint and its authentication parameters in one definition, specified as the endpoint of an Apex callout. The LWC Developer Guide says that if you need authentication headers in a call, send the request from Apex with HttpRequest, because providing your keys in JavaScript is not secure. So the integration user’s secret never leaves Apex, and the URL is the ticket Apex hands out.
  4. The component opens the URL; it does not fetch it. The LWC Developer Guide says that to call a third-party API from JavaScript you must first add its base URL to the Trusted URLs page in Setup, as a trusted URL (previously CSP Trusted Site). A fetch from the component to the external host needs that entry. The way I read the Content Security Policy overview, the directives Salesforce sets to ‘self’ cover resources a page loads: fonts, images, media, frames, styles, and connect-src for a fetch. Sending the user to a URL with an anchor or window.open is not one of those loads, and the Manage Trusted URLs help page points user redirection to a separate Setup page, Manage Redirections to External URLs. That is my reading of the directive list, not a sentence on either page, so the thing to check for a navigation is that redirection setting rather than a trusted URL.

Two more that are not Salesforce questions but will come up. Who logs the download, now that Apex only sees the link request and not the bytes? And what does the user see if the URL expires before they click, because an expired link is a blank browser tab unless the external system returns something friendlier.

How do I decide which pattern to use?

By the size of the largest file the feature must serve, and by how many people download at once. Work those out first; the pattern follows.

Largest file Pattern What to watch
Under about 4 MB Apex controller: callout, base64, return the string Heap and callout size: the 6 MB response is the hard stop, and the Blob and its string share the heap. Check Apex’s own time on the biggest file against the concurrent long-running limit.
Tens of MB Direct download only: Apex fetches a signed URL, the browser streams the file The external system must issue signed URLs and be reachable from browsers. The component opens the URL rather than fetching it.
Hundreds of MB or more Direct download plus an asynchronous “prepare” step on the external side The external system builds the file first and tells you when it is ready. The component polls or subscribes, then opens the URL.

The under-4 MB row is reasoning from the limits, not a measurement. The 6 MB synchronous callout response is the hard stop, and base64 plus the heap bring the practical ceiling to about 4 MB. If your files sit near that line today, they will cross it, and the signed URL pattern costs no more to build than the controller does. I would build the signed URL pattern for anything the business describes as “a report” and keep the controller for thumbnails, receipts and the kind of file that is small by nature.

The thing to take away is that the heap increase in Winter ‘27 is good news for a lot of Apex and almost irrelevant here. The file comes in through a callout, and the callout response limit did not move. For anything beyond a few megabytes, the right answer was already to keep the bytes out of Salesforce, and it still is.

References

  • Apex Developer Guide, Execution Governors and Limits, Winter ‘27 (API version 68.0): total heap size 10 MB synchronous and 25 MB asynchronous, and the footnote that exceeding it throws an uncatchable System.LimitException. Maximum size of a callout request or response, 6 MB synchronous or 12 MB asynchronous, and the footnote that those sizes count towards the heap. The concurrent long-running transactions row: one per 100 licences, minimum 10, maximum 50, with HTTP callout processing time excluded.
  • Salesforce Help, Winter ‘27 Release Notes, Handle Larger Datasets with Increased Apex Heap Limits: the increase from 6 MB to 10 MB synchronous and from 12 MB to 25 MB asynchronous, enabled automatically on the release schedule. Also Limits.getLimitHeapSize() to confirm an org’s figure, and the Apex Settings checkbox for nonproduction orgs that enforces the previous limit until Spring ‘27.
  • Lightning Web Components Developer Guide, Call APIs from JavaScript: a third-party API call needs its base URL on the Trusted URLs page, and authentication headers belong in Apex HttpRequest because keys in JavaScript are not secure.
  • Lightning Components Security guide, Content Security Policy Overview: the font-src, img-src, media-src, frame-src, style-src and connect-src directives set to ‘self’, changed by adding trusted URLs.
  • Salesforce Help, Manage Trusted URLs: each CSP directive controls access to a resource type, and user redirection to external URLs is handled by Manage Redirections to External URLs.
  • Salesforce Help, Named Credentials: a named credential specifies the URL of a callout endpoint and its authentication parameters in one definition, specified as the endpoint of an Apex callout.
  • IETF, RFC 4648, The Base16, Base32, and Base64 Data Encodings: base64 encodes each 3-byte group as 4 characters, the source of the one-third overhead.

Need an integration that moves large files?

I take short specialist contracts to design and deliver Salesforce integrations, including file flows between LWCs, Apex and external systems.

Discuss your integration