Fix Guides

Fix Apex CPU Timeout and Heap Size Too Large Errors in Salesforce

Step-by-step fix guide with AI-powered diagnosis from BuildForce.

An Apex CPU timeout or heap size error means one transaction tried to process too much data or run too much compute at once — almost always a query or DML statement inside a loop, or a batch job with no chunking. Both are fixed the same way: bulkify the logic so it runs once per transaction instead of once per record, make every query selective, and move genuinely large-data-volume work to asynchronous Apex, where the ceiling on both limits is six times higher.

The Error: "System.LimitException: Apex CPU time limit exceeded"

This fires when synchronous Apex consumes more than 10,000ms of actual processor compute in a single transaction (60,000ms for async Apex — Queueable, Batch, Future). CPU time excludes time spent waiting on a SOQL query, a DML statement, or a callout to return — it only counts the work your code itself does. A transaction that's slow but shows low CPU time in the debug log has a database or network bottleneck, not a CPU-limit problem, and bulkifying loops won't fix that case.

The Error: "System.LimitException: Apex heap size too large"

This fires when the memory your variables, collections, and query results occupy at one moment exceeds 6MB for synchronous Apex (12MB for async). Unlike CPU time, heap size has nothing to do with how much compute your code does — a single List holding 50,000 wide Case records can exceed the heap ceiling while barely touching the CPU budget. This is the more common failure on Case merge, escalation, and nightly SLA-recalculation jobs, where the object carries long text fields and related attachments.

Common Causes

  • SOQL or DML inside a loopA query or a record.save() call placed inside a for-loop runs once per iteration instead of once per transaction. At 200 Cases that's 200 queries; each one also holds its result set in heap until the transaction ends.
  • Non-selective queries on Case and related objectsSELECT Id, Subject, Description FROM Case with no indexed WHERE clause returns every column you asked for, for every row that matches — often the full object. Filtering late (after the query, in Apex) instead of in the SOQL WHERE clause means the unfiltered result set sits in heap first.
  • Recursive trigger chainsA Case update that fires a trigger, which updates a related Case, which fires the trigger again, re-runs the same expensive logic multiple times inside one transaction — multiplying both the compute and the memory footprint on every pass.
  • Large data volume with no chunkingA nightly job that recalculates SLA milestones, re-scores every open Case, or merges duplicate Cases across the full object with no LIMIT or batching processes the entire table in one transaction instead of in governor-limit-sized chunks.
  • Attachments and long text fields held in memoryCase objects carry Description, email body text, and related Attachment or ContentVersion records. Querying those fields for a large result set — instead of paging or querying only the records that changed — is a fast way to exceed the 6MB synchronous heap ceiling.

How to Fix It — Step by Step

  1. 1. Pull the debug log and read Cumulative Limit Usage. Setup → Debug Logs, reproduce the failure, and open the CUMULATIVE LIMIT USAGE section of the resulting log. It reports CPU time and heap size consumed at the point of failure — confirm which limit actually tripped before changing code.
  2. 2. Bulkify every SOQL query and DML statement. Move queries and record.save() calls outside of loops. Query once into a List or Map before the loop, operate on the in-memory collection, then issue one bulk DML call (update, insert) after the loop completes.
  3. 3. Make every query selective. Add a WHERE clause on an indexed field (Id, a custom External ID field, an indexed lookup) and query only the columns the transaction actually uses. Filter in SOQL, not in an Apex loop after the fact — an unfiltered result set has already consumed heap by the time Apex code sees it.
  4. 4. Add a recursion guard. Use a static Set<Id> or static Boolean in a helper class to track which Case Ids have already run through the expensive logic in the current transaction, so a trigger chain doesn't recompute the same work on every save.
  5. 5. Move large-data-volume work to Queueable or Batch Apex. Batch Apex processes records in configurable chunks (200 by default) across separate transactions, each with its own fresh 12MB heap and 60,000ms CPU budget. Anything touching more Cases than a single save should run there, not synchronously.
  6. 6. Release memory you no longer need. Set large collections to null once they're no longer needed within the transaction, rather than holding every intermediate result until the method returns. This matters most in loops that build up multiple large Lists or Maps in sequence.
  7. 7. Re-test at 200+ records before calling it fixed. Run the fix against a bulk data set, not a single Case. Both CPU and heap bugs are invisible at small scale and only appear once the collection sizes and iteration counts reflect production volume.

Example: Bulkifying a Case Escalation Loop

// BAD — query and update inside the loop: N queries, N DML statements,
// and every result set held in heap until the transaction ends
for (Case c : triggerNewCases) {
    Case fullCase = [SELECT Id, Description, Entitlement.Name
                     FROM Case WHERE Id = :c.Id]; // 1 query per record
    fullCase.Status = 'Escalated';
    update fullCase;                              // 1 DML per record
}

// GOOD — one selective query, one bulk DML call
Set<Id> caseIds = new Map<Id, Case>(triggerNewCases).keySet();
List<Case> casesToEscalate = [
    SELECT Id, Status FROM Case
    WHERE Id IN :caseIds AND Status != 'Escalated'
];
for (Case c : casesToEscalate) {
    c.Status = 'Escalated';
}
update casesToEscalate;

Prevention

  • Bulk-test every Case trigger and batch job at 200+ records — the Salesforce bulk standard — before it reaches production, not after a real import or escalation run trips the limit.
  • Read Cumulative Limit Usage on every debug log, not just the ones that already failed, to catch a class approaching the ceiling before it crosses it.
  • Chunk large-data-volume jobs into Batch Apex with a scope size (200 default) sized to the actual row width — wide Case records with attachments may need a smaller scope than a narrow lookup object.
  • Review new Apex for loop-embedded SOQL/DML and non-selective queries before merge, not after a governor-limit failure surfaces it in production.

This Error Is a Symptom, Not the Root Cause

A query-in-a-loop or non-selective SOQL pattern that shipped in one Case trigger is a style the same class, or the same copied boilerplate, likely repeats elsewhere in the org. Run BuildForce's free health check to find every instance of this pattern in your org, not just the one that just failed — then connect your org so BuildForce can scan every Apex class for CPU and heap risk before the next bulk case load or SLA recalculation finds the next one.

How BuildForce Prevents This

FAQ

Is a CPU timeout the same problem as a heap size error?

No, but they share a root cause. CPU time (10,000ms synchronous, 60,000ms async) measures processor compute; heap size (6MB synchronous, 12MB async) measures the memory your variables, collections, and query results occupy at one moment. A transaction that loads 50,000 Case records into a List<Case> can blow the heap without using much CPU time at all. Both are governor limits, and both are almost always fixed by processing less data per transaction, not by requesting a higher limit.

Why does this show up in Service Cloud specifically?

Case-triggered automation runs on high-volume objects with wide records — attachments, email threads, and long descriptions — and it's common for one case save to fire entitlement processes, assignment rules, and 2-3 triggers in the same transaction. A batch job that re-escalates or merges cases, or a nightly job that recalculates SLA milestones across every open case, is the pattern most likely to load enough case and related-object data into memory to exceed either limit.

Does adding more heap or CPU time to my org fix this?

There's no setting for it — Salesforce enforces these as fixed per-transaction limits for every org, on every edition. The only ways to get more headroom are moving the work to asynchronous Apex, which raises the synchronous ceiling (10,000ms to 60,000ms CPU, 6MB to 12MB heap), or reducing how much data one transaction touches. There's no purchasable upgrade.

My unit tests pass with a handful of records — why does this fail in production?

A List.contains() call inside a loop, or a query that returns every field on every related record, costs almost nothing at 5 records and a lot at 5,000. Governor-limit bugs are volume-dependent by nature, so a test with 1-20 records will not catch them. Test with 200+ records — the Salesforce bulk standard — before calling a fix verified.

Can Buildforce catch these before a production job fails?

Yes. Buildforce reads your org's actual Apex classes and triggers and flags nested-loop SOQL, non-selective queries on Case and related objects, and missing recursion guards before a batch job or a case-volume spike trips the limit — instead of finding out from a failed job log.