Diagnosing Apex CPU Time Limit Exceeded: A Governor Limit Checklist
A step-by-step diagnostic guide for Apex CPU time limit exceeded errors, using the Limits class to pinpoint whether SOQL, heap, or recursion is the real cause.
22 May 2026, 19:05 UTC

Recognizable Condition
An Apex transaction aborts with System.LimitException: Apex CPU time limit exceeded or a related governor-limit message such as Too many SOQL queries. In the debug log, the LIMIT_USAGE_FOR_NS section shows the consumed resource at or near its ceiling. The failure is often intermittent: it appears under load or with larger data volumes, not in small manual tests.
The useful takeaway is that the error names the symptom, not the root cause. Before optimizing, measure which resource is exhausted using the Limits class. A CPU error can be caused by inefficient loops, recursive triggers, or excessive SOQL inside a loop.
Cause and Diagnostic Table
| Condition | Likely cause | Diagnostic signal |
|---|---|---|
| CPU time limit exceeded | Long-running loops, complex calculations, recursive triggers, heavy string operations | Limits.getCpuTime() approaches Limits.getLimitCpuTime() |
| Too many SOQL queries | SOQL inside loops, missing bulkification | Limits.getQueries() near Limits.getLimitQueries() |
| Maximum heap size reached | Large in-memory collections, string concatenation, unstreamed query results | Limits.getHeapSize() near Limits.getLimitHeapSize() (commonly 6 MB) |
| Recursive trigger | Trigger re-enters itself on DML | Stack trace repeats the same trigger frame |
Ordered Diagnostic Checks
1. Baseline the Transaction with the Limits Class
Run the following in the Developer Console (Debug > Open Execute Anonymous Window). You need the Author Apex permission. Replace Account with the object your transaction touches.
System.debug('CPU used: ' + Limits.getCpuTime() + ' / ' + Limits.getLimitCpuTime());
System.debug('SOQL used: ' + Limits.getQueries() + ' / ' + Limits.getLimitQueries());
System.debug('Heap used: ' + Limits.getHeapSize() + ' / ' + Limits.getLimitHeapSize());
Expected check: The debug log prints current consumption against the ceiling for this context. Risk: Anonymous Apex runs in a transaction and consumes limits, but the snippet above performs no DML and changes no state.
2. Inspect Trigger Bulkification
Bulkification means a trigger processes all records in Trigger.new as a single set, not one at a time. A non-bulkified pattern issues one SOQL query per record:
trigger AccountTrigger on Account (before update) {
for (Account a : Trigger.new) {
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :a.Id];
// process
}
}
A bulkified version collects IDs first, queries once, and uses a map:
trigger AccountTrigger on Account (before update) {
Set<Id> acctIds = new Set<Id>();
for (Account a : Trigger.new) {
acctIds.add(a.Id);
}
Map<Id, List<Contact>> contactsByAcct = new Map<Id, List<Contact>>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :acctIds]) {
if (!contactsByAcct.containsKey(c.AccountId)) {
contactsByAcct.put(c.AccountId, new List<Contact>());
}
contactsByAcct.get(c.AccountId).add(c);
}
// process using the map
}
3. Profile Heap and Query Usage
Heap is the in-memory space for objects and variables. A synchronous transaction commonly allows about 6 MB. Large lists or repeated string concatenation can exhaust it. Use a SOQL for loop to stream results in batches of 200 instead of loading everything at once:
for (List<Account> batch : [SELECT Id, Name FROM Account]) {
// process batch
}
4. Rule Out Recursion
If the stack trace shows the same trigger firing repeatedly, add a static guard so the trigger runs only once per transaction:
public class AccountTriggerHandler {
private static Boolean isExecuting = false;
public static void handle() {
if (isExecuting) return;
isExecuting = true;
// logic
}
}
Fixes Tied to Findings
CPU Time Near the Ceiling
Move heavy processing to asynchronous Apex—Queueable, Batch, or Scheduled—where CPU ceilings are separate and generally more generous. Inside synchronous code, reduce nested loops and use Map lookups instead of iterating lists repeatedly.
SOQL Count Near the Ceiling
Move queries outside loops. Use a single query with WHERE Id IN :collection and leverage relationship queries (for example, SELECT Id, (SELECT Id FROM Contacts) FROM Account) to avoid child queries in loops.
Heap Size Near the Ceiling
Stream large query results with SOQL for loops. Avoid building giant strings with +; use String.join or write to Blob when appropriate. Discard intermediate collections as soon as they are no longer needed.
Recursive Trigger Execution
Apply the static guard pattern above, or adopt a trigger framework that centralizes recursion control and bulkification.
Concrete Reproduction Test
Before applying a fix, write a test that deliberately exceeds the limit and asserts the expected exception. This confirms you are solving the right problem.
@isTest
static void reproduceCpuLimit() {
List<Account> testData = new List<Account>();
for (Integer i = 0; i < 10000; i++) {
testData.add(new Account(Name = 'Test ' + i));
}
Test.startTest();
try {
// Simulate CPU-heavy processing
for (Integer i = 0; i < 100000; i++) {
String s = 'x' + i;
}
} catch (System.LimitException e) {
System.assert(e.getMessage().contains('CPU time limit exceeded'));
}
Test.stopTest();
}
Test data is isolated and rolled back automatically, so this test changes no org state.
Escalation Criteria
- Limits are exhausted even after bulkification, recursion guards, and async offload.
- Identical data volumes pass in sandbox but fail in production, suggesting org-specific limit differences or configuration drift.
- A trivial transaction with no DML or loops throws a
LimitException, which may indicate a platform defect rather than a code issue.
Limitations and Verification
Governor limits are enforced per user and per transaction. A fix validated in a developer sandbox may fail in production if production has different limit allocations or larger data volumes. Always validate in a full-copy or production-like sandbox with representative data.
Asynchronous contexts have their own governor limits; a CPU fix for synchronous code does not automatically apply to Batch or Queueable jobs.
Practical check: After deploying a change, run the original transaction path in a sandbox and inspect the debug log. Confirm that CPU time in LIMIT_USAGE_FOR_NS stays below the ceiling and that no LimitException is thrown. Re-run the full test suite, because large batch jobs can reintroduce limit pressure after a narrow fix.
If you deploy a trigger or class change, rollback is simply redeploying the previous version through your change set or source-control pipeline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.