System.LimitException: Apex CPU time limit exceeded
Your transaction spent more than 10 seconds (sync) or 60 seconds (async) of CPU time inside Apex code. This counts compute, not waiting — SOQL and DML time don't count, but loops, sorting, parsing, and complex collections do.
Also seen asApex CPU time limit exceeded·CPU time limit exceeded·System.LimitException: Apex CPU time limit
A nightly batch that summarizes Opportunity activity has run cleanly for six months. The last release added a small enhancement: a method that scores each Opportunity against a list of keywords. The batch dies on Tuesday with System.LimitException: Apex CPU time limit exceeded. The scoring method looks innocent. The team has eight hours to find the offender before the next batch window opens.
What the platform is checking
Apex transactions have a maximum CPU time budget. The synchronous limit is 10,000 milliseconds. The asynchronous limit (batch, queueable, future, scheduled) is 60,000 milliseconds. Time spent in CPU-bound work counts: loops, string manipulation, JSON serialization, math, regex matching, sorting, complex collection operations. Time spent waiting for database I/O, callouts, or external resources does not count.
The runtime samples CPU time across the transaction. When the accumulated CPU time exceeds the budget, the runtime throws LimitException: Apex CPU time limit exceeded. The transaction aborts and any uncommitted DML rolls back. The exception fires at the next instruction that crosses the threshold, which is rarely the actual hotspot. The expensive code may have run for the entire 60 seconds before the limit fired at a benign line.
The limit exists because Apex runs on shared infrastructure. A single tenant cannot monopolize compute resources. The budget per transaction is generous enough for any well-written business logic and tight enough to force inefficient code into refactoring or async patterns.
The error surface is wide. The slow code is somewhere in the transaction, but the stack trace points only at the line where the threshold was crossed. Finding the hotspot requires profiling, often via Limits.getCpuTime() instrumentation or via debug logs.
What is slow in Apex
Three categories cover most CPU-limit failures.
Nested loops over large collections. A loop over Opportunities that for each Opportunity loops over related Contacts produces O(n*m) operations. With 10,000 Opportunities and 5 Contacts each, that is 50,000 iterations. With 100,000 Opportunities and 50 Contacts, that is 5,000,000 iterations. The math is straightforward; the consequence is brutal.
String manipulation in loops. Building a large CSV by concatenating strings inside a loop allocates a new String on every iteration. With 100,000 rows and 50 characters per row, the cumulative work is much more than 5 million character operations because each concatenation copies the existing buffer.
Regex over wide text. Pattern.matcher(text).find() on large text can be slow, especially when the regex has nested quantifiers or alternations. A regex that runs in microseconds on small inputs can spend seconds on large ones.
The broken example
A batch that scores Opportunities against a keyword list:
public class OpportunityScoringBatch implements Database.Batchable<SObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator('SELECT Id, Description, Stage_Notes__c FROM Opportunity');
}
public void execute(Database.BatchableContext bc, List<Opportunity> scope) {
List<String> keywords = new List<String>{'urgent', 'decision', 'budget', 'timeline', 'champion'};
List<Opportunity> toUpdate = new List<Opportunity>();
for (Opportunity o : scope) {
String combined = (o.Description != null ? o.Description : '') + ' ' +
(o.Stage_Notes__c != null ? o.Stage_Notes__c : '');
Integer score = 0;
for (String kw : keywords) {
Pattern p = Pattern.compile('(?i)\\b' + kw + '\\b');
Matcher m = p.matcher(combined);
while (m.find()) {
score++;
}
}
o.Score__c = score;
toUpdate.add(o);
}
update toUpdate;
}
public void finish(Database.BatchableContext bc) {}
}
The execute method compiles a new Pattern for every keyword on every Opportunity. With 200 records per batch and 5 keywords, that is 1,000 Pattern.compile calls per execute. Pattern compilation is not free. Add the regex matching itself, which scans potentially thousands of characters per Opportunity, and the CPU time accumulates fast.
The first few execute() calls finish in under a second. As scope size grows or Description fields contain larger payloads, the time per execute climbs. Eventually one execute crosses 60,000 ms and the batch fails.
The fix, three paths
Move loop-invariant work out of the loop. Compile Patterns once, reuse them across records:
public class OpportunityScoringBatch implements Database.Batchable<SObject> {
private static final List<Pattern> KEYWORD_PATTERNS;
static {
KEYWORD_PATTERNS = new List<Pattern>();
for (String kw : new List<String>{'urgent', 'decision', 'budget', 'timeline', 'champion'}) {
KEYWORD_PATTERNS.add(Pattern.compile('(?i)\\b' + kw + '\\b'));
}
}
public void execute(Database.BatchableContext bc, List<Opportunity> scope) {
List<Opportunity> toUpdate = new List<Opportunity>();
for (Opportunity o : scope) {
String combined = (o.Description != null ? o.Description : '') + ' ' +
(o.Stage_Notes__c != null ? o.Stage_Notes__c : '');
Integer score = 0;
for (Pattern p : KEYWORD_PATTERNS) {
Matcher m = p.matcher(combined);
while (m.find()) {
score++;
}
}
o.Score__c = score;
toUpdate.add(o);
}
update toUpdate;
}
public void finish(Database.BatchableContext bc) {}
}
The static initializer compiles each Pattern once. The execute method reuses the compiled patterns for every record. CPU time per execute drops dramatically.
Use a simpler algorithm. Regex is expensive when a simple substring check works. For keyword counting where word boundaries do not strictly matter, plain string operations are faster:
for (Opportunity o : scope) {
String combined = ((o.Description ?? '') + ' ' + (o.Stage_Notes__c ?? '')).toLowerCase();
Integer score = 0;
for (String kw : keywords) {
Integer idx = 0;
while ((idx = combined.indexOf(kw, idx)) != -1) {
score++;
idx += kw.length();
}
}
o.Score__c = score;
}
indexOf on a String is much cheaper than Pattern.matcher(...).find(). For exact-word matching, you sacrifice the word-boundary check; for most keyword scoring, the difference does not matter.
Reduce the batch size and increase parallelism. Batch jobs accept a scope size parameter. Smaller batches per execute() means less work per CPU budget. The total work is the same, but each execute is more likely to finish within the limit.
Database.executeBatch(new OpportunityScoringBatch(), 50);
A scope size of 50 instead of 200 quarters the work per execute. If the failure is borderline, the smaller scope avoids the limit. The total batch takes longer to finish in wall-clock time because there are more execute calls, but each one succeeds.
The fixed example
The full batch with all three optimizations:
public class OpportunityScoringBatch implements Database.Batchable<SObject>, Database.Stateful {
public Integer totalProcessed = 0;
public Integer totalErrors = 0;
private static final List<String> KEYWORDS = new List<String>{'urgent', 'decision', 'budget', 'timeline', 'champion'};
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Description, Stage_Notes__c FROM Opportunity WHERE IsClosed = false'
);
}
public void execute(Database.BatchableContext bc, List<Opportunity> scope) {
List<Opportunity> toUpdate = new List<Opportunity>();
for (Opportunity o : scope) {
try {
String combined = buildCombinedText(o);
o.Score__c = countKeywordMatches(combined);
toUpdate.add(o);
} catch (Exception e) {
totalErrors++;
}
}
if (!toUpdate.isEmpty()) {
update toUpdate;
totalProcessed += toUpdate.size();
}
}
private String buildCombinedText(Opportunity o) {
String desc = o.Description ?? '';
String notes = o.Stage_Notes__c ?? '';
return (desc + ' ' + notes).toLowerCase();
}
private Integer countKeywordMatches(String text) {
Integer score = 0;
for (String kw : KEYWORDS) {
Integer idx = 0;
while ((idx = text.indexOf(kw, idx)) != -1) {
score++;
idx += kw.length();
}
}
return score;
}
public void finish(Database.BatchableContext bc) {
System.debug('Scored ' + totalProcessed + ' opportunities, ' + totalErrors + ' errors');
}
}
The batch is now O(records * keywords) where each keyword check is a fast indexOf. The static keyword list avoids re-allocation. The Database.Stateful interface tracks totals across executions for reporting in finish().
Edge cases and gotchas
JSON serialization of large objects. JSON.serialize(largeList) can spend significant CPU time, especially on deeply nested structures. If you only need a subset of fields, build a smaller wrapper class with just those fields and serialize the wrapper.
Map operations on huge maps. myMap.keySet() allocates a new Set. myMap.values() allocates a new List. Doing either inside a loop allocates on every iteration. Pull them out:
Set<Id> keys = myMap.keySet();
for (Id key : keys) {
// use key
}
Sorting custom Comparable lists. List.sort() on a List<MyClass> requires the class to implement Comparable. The compareTo method is called O(n log n) times. Heavy compareTo logic compounds with the list size. Keep compareTo lean.
String.format with many arguments. String.format('{0} {1} {2} ... {99}', longList) is slower than expected. For very hot paths, concatenation or StringBuilder-style patterns (via List<String> joined with String.join) are faster.
Trigger CPU time aggregates. A trigger that runs in 50 ms on average can blow the limit when a bulk DML brings 200 records in one transaction. CPU time aggregates across the entire transaction, including all triggers, all workflow rules, all flows, all formula evaluations.
Formula fields and roll-up summary fields are CPU. Complex formula fields with many cross-object references can add CPU time on every record read. A roll-up summary that aggregates over thousands of children adds to the CPU budget. Auditing formulas and roll-ups is part of investigating a CPU limit failure.
Defensive habits
Profile before optimizing. Add System.debug(LoggingLevel.WARN, 'Before step X: ' + Limits.getCpuTime()) around suspect blocks. The debug log reports actual CPU time at each checkpoint and tells you where the hotspot lives.
Pre-compute and cache. Anything that does not change per-record should live above the per-record loop. Compiled patterns, configuration lookups, SOQL results for reference data, picklist value sets all belong in static initializers or in fields populated once at the start of execute().
Use SOQL aggregate queries for counting. SELECT COUNT() FROM Opportunity WHERE ... runs server-side and returns one number. Loading the records into Apex and counting them in code is much slower.
Batch and chain. When the work is genuinely large, no single transaction can hold it. Database.Batchable processes records in chunks. Queueable.execute can enqueue follow-up jobs to spread work across transactions. Each transaction gets its own CPU budget.
Avoid recursive triggers without a guard. A trigger that updates the record it just received fires itself again, doubling the CPU work each pass. Trigger frameworks with built-in recursion guards prevent this class of bug.
Test patterns
A test that exercises the CPU-intensive path with a realistic record count:
@IsTest
static void scoringBatchHandlesFullScope() {
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 200; i++) {
opps.add(new Opportunity(
Name = 'Test ' + i,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Description = 'urgent decision needed before timeline expires; budget is approved'
));
}
insert opps;
Test.startTest();
Database.executeBatch(new OpportunityScoringBatch(), 200);
Test.stopTest();
for (Opportunity o : [SELECT Id, Score__c FROM Opportunity WHERE Score__c != null]) {
System.assert(o.Score__c >= 4, 'Should match at least 4 keywords');
}
}
The test runs the batch with a full scope and verifies the scoring still produces expected results. CPU usage is measured implicitly: if the test passes, the limit was not exceeded.
Diagnosing in production
When the LimitException fires:
- Capture the debug log from the failing transaction.
- Find the call to
Limits.getCpuTime()instrumentation or add it on the next run. - Identify the block of code that consumed the most CPU.
- Apply the appropriate fix: pre-compute, simplify the algorithm, reduce scope size, or split into async.
- Re-run with monitoring and confirm CPU usage stays well below the limit.
A CPU usage of 50,000-60,000 ms in a 60,000 ms budget is borderline. The same code under slightly different conditions (more data, slower instance) crosses the limit. Aim for a comfortable margin (under 30,000 ms for batches, under 5,000 ms for synchronous transactions).
A subtle case: trigger interactions multiply CPU work
A single trigger that processes 200 records in under 100 ms can still blow the limit when combined with downstream effects. A workflow rule that fires field updates on the same records causes the trigger to evaluate again. A flow that runs after the trigger updates a related record, which has its own trigger that does another pass. By the time the transaction commits, the trigger has run three or four times, the flow has run twice, and the cumulative CPU exceeds 10 seconds.
The way to diagnose this is to add System.debug(LoggingLevel.WARN, 'Trigger pass ' + Trigger.size + ' at ' + Limits.getCpuTime()) at the top of every trigger handler. The log shows each pass with its CPU snapshot. A pass that consumes 500 ms is fine; the same trigger running five times consumes 2.5 seconds before any other automation runs.
The fix is rarely to optimize within the trigger. The fix is to reduce the number of passes. Avoiding self-modifying patterns, consolidating multiple triggers into one handler, and skipping the work when fields have not actually changed all reduce pass count without changing per-pass logic.
Quick recovery checklist
- Profile to find the hotspot.
- Move loop-invariant work out of the loop.
- Replace expensive algorithms with cheaper ones where possible.
- Reduce scope size or chain work across transactions.
- Add monitoring to catch regressions.
CPU limit failures are usually about a single bad pattern. Once identified, the fix is targeted and the regression test prevents recurrence.
Further reading from Salesforce
- Apex Developer Guide: Execution Governors and Limits
- Apex Developer Guide: Limits Class
- Apex Developer Guide: Batch Apex
- Architect: Large Data Volumes
- Trailhead: Apex Performance
Related dictionary terms
Share this fix
Related Governor limit errors
Apex code is approaching a governor limit warning email
Governor limitSalesforce sent your team an email saying Apex is approaching a governor limit (typically 80% of CPU, SOQL, DML, or callout caps) but didn't…
STORAGE_LIMIT_EXCEEDED: storage limit exceeded
Governor limitYour org has hit its data storage or file storage cap. New record creation fails until you free space or buy more storage. Audit which objec…
System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out
Governor limitYou did a DML statement and then tried to make an HTTP callout in the same synchronous transaction. The platform forbids this because the ca…
System.LimitException: Apex heap size too large
Governor limitYour transaction is holding more data in memory at once than Apex allows: 6 MB synchronous, 12 MB asynchronous. Usually it's a `List<SObject…
System.LimitException: Maximum trigger depth exceeded
Governor limitTriggers can fire other triggers, which can fire more triggers — but only 16 levels deep. Hit 17 and the platform stops the whole transactio…