Fix "Apex CPU Time Limit Exceeded" in Salesforce

By Prit Sakhvala, Salesforce Developer · Updated August 2026

"Apex CPU time limit exceeded" means your transaction used more than 10 seconds of CPU (60 seconds for async). Database time doesn't count; the culprit is computation: loops over large collections, nested trigger/Flow recursion, or inefficient code in triggers and managed packages. Find the hotspot in the debug log, then bulkify, cache with maps, or move work async.

The exact error, and what Salesforce is telling you

The error surfaces in one of two forms. In a synchronous transaction (a user saving a record, a Lightning component action, a Visualforce postback), you get:

System.LimitException: Apex CPU time limit exceeded

In asynchronous contexts the wording shifts slightly, and the governor budget is six times larger:

System.LimitException: Apex CPU time limit exceeded (Batchable, 60000 ms)

Three properties of this error make it uniquely frustrating, and they are worth internalising before you start debugging:

That last point matters more than it first appears. If you are seeing this error occasionally, you do not have an occasional problem. You have a transaction permanently sitting near the ceiling, and normal variance is pushing it over. The fix is never to retry; it is to create headroom.

What counts toward CPU time, and what doesn't

Counts toward CPUDoes NOT count
Apex statement execution (loops, string/JSON processing, math)SOQL query execution time (database)
Trigger logic (yours and managed packages')DML database time
Flow element execution, formulas, validation rules, workflowHTTP callout wait time
Declarative automation triggered by your DMLTime spent waiting on locks

This is why the error is confusing: the visible slow part of a transaction (a big query) is often free, while an innocent-looking loop is what burns the budget.

The usual suspects

  1. Loops over loops. Nested iteration over related records (O(n²) comparisons) is the single most common cause. 200 trigger records × 1,000 comparisons each is 200,000 iterations before you've done anything useful.
  2. Trigger recursion. An update trigger that performs updates re-fires automation. Two or three re-entries multiply all trigger CPU.
  3. Non-bulkified helper methods called once per record instead of once per batch.
  4. Record-triggered Flows stacking on triggers. Each Flow element costs CPU; a loop inside a Flow over hundreds of records adds up fast.
  5. Heavy string/JSON processing: serializing large payloads, regex over long text, building huge strings by concatenation.
  6. Managed package automation you can't see, but which absolutely bills to your transaction's CPU.

1. Nested loops: the O(n²) trap

This is the cause in the clear majority of CPU limit cases, and it is almost always written by someone who knew to avoid SOQL in loops but did not think of iteration itself as expensive. The pattern looks harmless:

// ANTI-PATTERN: 200 opportunities x 5,000 line items = 1,000,000 iterations for (Opportunity opp : Trigger.new) { for (OpportunityLineItem oli : allLineItems) { if (oli.OpportunityId == opp.Id) { opp.Total_Custom__c += oli.TotalPrice; } } }

There is no SOQL and no DML inside the loop, so this passes most code reviews. But the comparison count is the product of the two collection sizes. At 200 × 5,000 you are executing a million string comparisons and a million property reads, which comfortably exceeds 10 seconds on its own.

The fix is to build a Map once, converting the inner scan into a hash lookup:

// CORRECT: group children once, then one pass over parents Map<Id, List<OpportunityLineItem>> itemsByOpp = new Map<Id, List<OpportunityLineItem>>(); for (OpportunityLineItem oli : allLineItems) { if (!itemsByOpp.containsKey(oli.OpportunityId)) { itemsByOpp.put(oli.OpportunityId, new List<OpportunityLineItem>()); } itemsByOpp.get(oli.OpportunityId).add(oli); } for (Opportunity opp : Trigger.new) { for (OpportunityLineItem oli : itemsByOpp.get(opp.Id)) { opp.Total_Custom__c += oli.TotalPrice; } }

Total iterations drop from 1,000,000 to roughly 10,200 (5,000 to build the map, then 200 parents plus the 5,000 children distributed among them), a reduction of about 99%. This single refactor resolves more CPU limit errors than every other technique combined. The rule to remember: if you are searching a collection inside a loop, you need a Map.

2. Trigger recursion

A trigger that updates its own object re-enters the entire automation stack: your trigger, every other trigger on that object, all record-triggered Flows, all validation rules and workflow. Two or three re-entries multiply your total CPU by the same factor.

public class TriggerGuard { private static Set<Id> processedIds = new Set<Id>(); public static List<Account> filterUnprocessed(List<Account> records) { List<Account> toProcess = new List<Account>(); for (Account acc : records) { if (!processedIds.contains(acc.Id)) { processedIds.add(acc.Id); toProcess.add(acc); } } return toProcess; } }

A record-level Set<Id> guard is safer than a simple static Boolean hasRun flag. The boolean version silently skips legitimate processing when a transaction updates several batches of the same object, a common source of "the automation didn't run" bugs introduced while fixing CPU problems.

3. Repeated schema and describe calls

Describe calls are far more expensive than they look, and putting one inside a loop is a quiet way to burn seconds:

// ANTI-PATTERN: full describe on every iteration for (String fieldName : fieldNames) { Schema.SObjectType t = Schema.getGlobalDescribe().get('Account'); // ... }

Schema.getGlobalDescribe() builds a map of every object in the org on each call. Hoist it above the loop, or cache it in a static variable so it is computed once per transaction and reused.

4. String concatenation in loops

Apex strings are immutable, so result += chunk allocates a brand-new string containing everything accumulated so far on every pass. Building a 10,000-row CSV this way is quadratic in both CPU and heap, and frequently trips the heap size limit at the same time. Collect the pieces in a List<String> and call String.join(parts, '\n') once at the end.

Finding the hotspot in the debug log

Before changing any code, find out where the time is actually going. Optimising the wrong method is the most common way to waste a day on this error. The following procedure needs nothing but the Developer Console.

Step 1: Capture the log at the right level

Set your trace flag so that the timing data you need is present without drowning the log in noise. For CPU work the important setting is PROFILING_INFO, and you want APEX_CODE high enough to see code unit boundaries:

ApexCode: FINE ApexProfiling: FINEST Database: INFO System: INFO Workflow: INFO Validation: INFO

Avoid FINEST on ApexCode unless you truly need statement-level detail. It inflates the log, and any log that exceeds the 20 MB cap gets truncated, usually cutting off the summary section you were looking for. See debug log levels explained for the full matrix.

Step 2: Read the limit summary first

Jump to the end of the log and find the LIMIT_USAGE_FOR_NS block. This tells you immediately whether CPU is genuinely your problem, or whether it is a symptom of something else:

LIMIT_USAGE_FOR_NS|(default)| Number of SOQL queries: 12 out of 100 Number of DML statements: 4 out of 150 Maximum CPU time: 9840 out of 10000 ******* CLOSE TO LIMIT

If CPU is near the cap while SOQL and DML sit comfortably low, the cause is computation: loops, string work, or Flow elements. If SOQL is also high, the real problem is often query volume driving iteration, and fixing the queries resolves the CPU symptom as a side effect.

Note that in an org with managed packages you will see several LIMIT_USAGE_FOR_NS blocks, one per namespace. CPU time is the one governor limit that is shared across all namespaces, so a package's consumption counts against your total even though it appears in a separate block.

Step 3: Rank the code units by elapsed time

Every log line carries a nanosecond elapsed counter, e.g. (5348351). The manual method:

14:23:07.1 (120000000)|CODE_UNIT_STARTED|[EXTERNAL]|OpportunityTrigger on Opportunity trigger event AfterUpdate 14:23:09.8 (2830000000)|CODE_UNIT_FINISHED|OpportunityTrigger on Opportunity trigger event AfterUpdate

2,830,000,000 − 120,000,000 = 2.71 s wall time in that trigger. Do this for every CODE_UNIT pair, rank them, then drill into the worst one. Also check the summary at the end of the log:

LIMIT_USAGE_FOR_NS|(default)| Maximum CPU time: 9840 out of 10000 ******* CLOSE TO LIMIT

Doing this by hand across a 50,000-line log is exactly the tedium a debug log analyzer removes. ForceLens's Performance tab ranks code units by time consumed, its Timeline shows where the transaction's time physically went, and the Limits tab shows CPU consumption against the 10 s budget, parsed from the same log, in one click.

ForceLens Performance tab showing CPU time breakdown per code unit from a Salesforce debug log

Fixes that actually work

Which fix to reach for first

These are not equally valuable. Work down this table in order: the top row resolves most cases and costs the least effort.

Symptom in the logMost likely fixTypical CPU reduction
One trigger dominates; SOQL count is lowReplace nested loops with Map lookups90–99%
Same trigger appears 2–3 times in the logAdd a recursion guard50–70%
Many Flow elements, high element countConsolidate Flows; move loops to Apex40–80%
Time spread evenly, nothing dominatesMove non-essential work asyncVaries
A managed package namespace dominatesScope package triggers; contact vendorVaries

Moving work asynchronous

When the computation is genuinely necessary, relocate it rather than optimise it. Async contexts get 60,000 ms instead of 10,000 ms and run outside the user's save, so the record saves immediately:

public class RollupProcessor implements Queueable { private Set<Id> accountIds; public RollupProcessor(Set<Id> accountIds) { this.accountIds = accountIds; } public void execute(QueueableContext ctx) { // heavy computation now runs with a 60s budget } } // from the trigger: if (!System.isBatch() && !System.isFuture()) { System.enqueueJob(new RollupProcessor(affectedIds)); }

Two constraints to respect. A synchronous transaction may enqueue up to 50 Queueable jobs, but an asynchronous one may enqueue only one, which is why the guard above checks isBatch() and isFuture() before enqueuing. And enqueue once for the whole collection rather than per record, or a 200-record bulk update will trade your CPU limit error for a queueable limit error.

Good candidates for async: roll-up recalculation, external system integration, notification sending, audit logging, data enrichment. Poor candidates: anything the user must see reflected on the record immediately, and anything a validation rule depends on.

Preventing the error from coming back

Fixing the immediate failure without changing how you build automation guarantees a repeat. Three practices matter:

Test at realistic volume. The default assumption of 200 records in a trigger test is the minimum, not a target. If your org processes 5,000-record data loads, write a test that does so. A transaction that passes at 200 records and fails at 2,000 has an O(n²) pattern in it that volume testing would have exposed in development.

Instrument before you need to. Limits.getCpuTime() is available anywhere in Apex and costs nothing meaningful to call. Logging it at the boundaries of expensive operations gives you the hotspot ranking for free the next time something degrades:

Long startCpu = Limits.getCpuTime(); processLargeDataset(records); System.debug(LoggingLevel.WARN, 'Rollup CPU: ' + (Limits.getCpuTime() - startCpu) + 'ms of ' + Limits.getLimitCpuTime() + 'ms');

Keep one trigger per object. Multiple triggers on the same object execute in an order Salesforce does not guarantee, which makes CPU profiling significantly harder and recursion far more likely. A single trigger delegating to a handler class keeps the execution path readable in the log, which is exactly what you need when you are staring at a 50,000-line log at 2am.

Frequently asked questions

What is the Apex CPU time limit in Salesforce?

10,000 ms (10 seconds) per synchronous transaction; 60,000 ms for asynchronous (batch, future, queueable). Exceeding it throws an uncatchable limit exception and rolls back the transaction. Full list: governor limits explained.

What does NOT count toward CPU time?

Database time: SOQL execution, DML database operations, and callout wait time are excluded. Computation, including Apex statements, trigger and Flow logic, validation, and managed package code, is what counts.

How do I find what's consuming CPU in a debug log?

Diff the elapsed-time counters between CODE_UNIT_STARTED/FINISHED pairs and check LIMIT_USAGE_FOR_NS at the end of the log, or let ForceLens's Performance and Timeline tabs compute the ranking automatically.

Can I catch the CPU limit exception in try/catch?

No. System.LimitException is uncatchable. Prevention (bulkification, recursion control, async offloading) is the only fix.

Why does it only fail in production, not in my sandbox?

Two reasons. Data volume: production triggers process full 200-record batches against millions of related rows, while sandbox tests run against a handful. And CPU time is measured on shared infrastructure, so the same code consumes measurably more under production load. A transaction using 9,600 ms in a sandbox has almost no margin and will fail in production.

Does managed package code count toward my CPU limit?

Yes. CPU time is one of the few governor limits shared across all namespaces. Package automation appears in its own LIMIT_USAGE_FOR_NS block but consumes the same 10-second budget. If a package dominates your log, look for scoping settings, reduce which record changes invoke it, or raise it with the vendor.

Do SOQL queries count toward CPU time?

The database execution time does not. However, iterating the returned rows in Apex does, so a query returning 50,000 rows still causes CPU problems through the loop that processes it. Reduce rows returned rather than only optimising query speed.

How much CPU headroom should I aim for?

Target under 5,000 ms (half the budget) for any transaction on a growing object. Anything above 8,000 ms should be treated as already failing, because normal production variance and future data growth will push it over.

Find your CPU hotspot in seconds

Open the failing transaction's log in ForceLens and the Performance tab ranks every code unit by time consumed, free, local, no setup.

Add ForceLens to Chrome (Free)