Voltar ao blog
Tiago Freitas

Winter '27 Doubled Your Async Apex Heap. Your Flush Branch Just Became Dead Code.

Winter '27 Doubled Your Async Apex Heap. Your Flush Branch Just Became Dead Code.

Winter '27 Doubled Your Async Apex Heap. Your Flush Branch Just Became Dead Code.

What changed, and is it already live in your org?

Winter '27 raises the Apex heap limit from 6 MB to 10 MB for synchronous transactions, and from 12 MB to 25 MB for asynchronous ones. That is a 66% and a 108% increase. The higher limits turn on automatically as an org moves to Winter '27. There is no opt-in, no release update to accept, no change request.

The first production upgrade weekend was 2026-08-29, covering a small set of instances. Most orgs land on the October weekends. Sandbox preview instances upgraded around 2026-08-28. If a client sits on an early-wave instance, this is not a preview concern, it shipped to their production over a weekend nobody scheduled work around.

One check settles it for any org, run in anonymous Apex:

System.debug('sync heap ceiling: ' + Limits.getLimitHeapSize());

That returns 6000000 or 10000000. For the async number, run the same line inside a Queueable, because the ceiling is per execution context, not per org.

Why is a bigger heap a risk instead of a win?

Large orgs guard heap with a proactive size check that flushes a buffer before the transaction dies. Raising the ceiling does not delete that branch, it stops exercising it. A flush that fired several times per batch execution now fires once, or not at all. The branch is still compiled, still deployed, and no longer runs.

Worth stating plainly, because it is the reason the pattern exists: a heap governor failure raises System.LimitException, and limit exceptions cannot be caught. There is no try/catch recovery. So the defensive code in every mature org is a check before the allocation, not a handler after it. It looks like this:

private void accumulate(SObject record) {
    buffer.add(record);
    if (Limits.getHeapSize() > Limits.getLimitHeapSize() * 0.8) {
        flush();
    }
}

This version adapts on its own, which is the good case. But read what changed operationally. In an async context it used to flush at roughly 9.6 MB. Now it flushes at roughly 20 MB. A path that ran on every job of any size now runs only on the largest ones, and the buffers it handles when it does run are twice the size it has ever been tested against.

The day a payload crosses the new ceiling, that flush executes for the first time in months, on a bigger dataset, in production. That is the exposure. Not the limit.

Which tests stay green while covering nothing?

A test that built an oversized payload to force the old 6 MB behaviour now completes on the happy path. If it asserts on the final result, it still passes. The flush branch simply stops executing, coverage on those lines drops, and a fully passing suite reports success for code that no longer runs.

The split is worth knowing before you go looking. A test that asserted on flush count, something like System.assertEquals(3, mock.flushCalls), fails loudly after the upgrade. That is the good outcome, because a red test gets fixed. A test that only asserted the returned records are correct passes either way and tells you nothing.

So the audit is not "which tests broke". It is the opposite: pull the Apex code coverage numbers before and after the upgrade and look for classes whose coverage dropped without a code change. Those are the branches that quietly went dark. A class that fell from 89% to 74% with an unchanged diff is pointing straight at the flush path.

How do you find code that hardcodes the old ceiling?

Some guards do not read Limits.getLimitHeapSize() at all, they compare against a literal. Those keep chunking at the Summer '26 boundary forever, leaving 40% of the new sync headroom and half the async headroom unused. Not dangerous, just work the platform no longer requires. One grep finds them.

grep -rnE '6000000|12000000|6 ?\* ?1024 ?\* ?1024|12 ?\* ?1024 ?\* ?1024' force-app/

Also grep for 5000000 and 10000000, because plenty of people wrote the guard at a round number below the real limit rather than at the limit itself. On a ten-year-old org this returns a mix of genuine heap guards, batch scope constants, and a couple of file-size validators that have nothing to do with heap. Read each hit, do not bulk-replace.

The fix is boring and correct: replace the literal with Limits.getLimitHeapSize() and keep the percentage. That version survives the next limit change without anyone reading a release note.

Can you roll the old limits back?

Partially, and not where it counts. Setup, Apex Settings carries a checkbox labelled "Enforce the Summer '26 Apex heap limit" that restores 6 MB and 12 MB. It is available in sandbox, Developer Edition and scratch orgs only. Once an org is on Winter '27 in production, the higher limits apply and there is no switch.

This changes where the work goes. The rehearsal window is the sandbox preview, not the upgrade weekend, and for early-wave instances that window already closed. For everyone else the checkbox is genuinely useful for one specific job: run the regression suite twice in the same preview sandbox, once with the box ticked and once without, and diff the coverage report. That gives you the list of newly-dark branches in an afternoon, without waiting for production to reveal them.

For orgs already upgraded, the equivalent is comparing a coverage run from before the weekend against one from after. If nobody captured the before, the grep in the previous section is the fallback.

Heap went up. CPU time did not. What is the binding limit now?

The CPU time limit is unchanged: 10,000 ms synchronous, 60,000 ms asynchronous. Query row limits, callout counts and DML limits did not move either. Heap was one ceiling in a room full of them, and raising it just means something else is now the first thing you hit on large-payload work.

This matters most for the design decision people will reach for first, which is collapsing a chunked integration back into a single transaction. Deserializing a large JSON blob is CPU-bound work, and JSON.deserialize holds the source string and the resulting object graph in heap at the same time. A 25 MB async ceiling does not buy you a 25 MB payload, it buys you something closer to half that, and you will hit 60 seconds of CPU before you hit the new heap number.

One limit I could not confirm and would not quote to a client: the HTTP request and response size limit historically tracked heap at 6 MB sync and 12 MB async. Whether it moved with this change is not stated in the coverage I have. If an integration plan depends on pulling a larger response in one callout, test it in a sandbox before designing around it.

Everything above comes from release coverage rather than Salesforce Help directly. The numbers your org actually enforces are the ones Limits.getLimitHeapSize() returns, and that beats every secondary source, this post included.

What to check this week

Five steps, in order, all of them checkable in a working day:

  1. Look up each org's instance on Salesforce Trust and read its Winter '27 production date. Multi-org clients get one plan per instance, since the waves run from 2026-08-29 into October.
  2. Run System.debug(Limits.getLimitHeapSize()); in anonymous Apex, and again inside a Queueable. Two numbers, thirty seconds, no ambiguity about which limits are live.
  3. Pull the Apex code coverage report and compare it against a run from before the upgrade weekend. Classes that lost coverage with no code change are your dark branches.
  4. Run the grep for hardcoded heap constants. Replace the literals with Limits.getLimitHeapSize() and keep the percentage threshold.
  5. For any flush or chunking branch that now rarely fires, write a test that calls it directly rather than trying to reach it through a payload big enough to trigger the guard. Constructing a 20 MB test fixture is slow and brittle. Calling flush() with a populated buffer tests the same logic in milliseconds.

Step 5 is the one that keeps paying after this release. A branch that only executes when a governor limit is near is a branch you cannot reliably reach from a test, which is exactly why it rots. Make it callable, and the next limit change is a coverage report instead of an incident.