You get 10,000ms of CPU per synchronous transaction (60,000ms async), shared by your Apex, cascaded triggers, Flow execution, and even certified managed packages — CPU is one of the limits packages don't get separately. Database time and callout wait don't count; this is purely about computation. Find the hotspot with Limits.getCpuTime() checkpoints, not guessing. Usual causes: nested loops, trigger recursion, and repeated expensive calls inside loops.
What the error actually measures
Salesforce gives a synchronous transaction 10,000ms of CPU time on the application server; asynchronous contexts (@future, Queueable, Batch, Scheduled) get 60,000ms. Cross it and you get an uncatchable System.LimitException and a full rollback — same enforcement mechanism as the SOQL and DML limits, but a completely different thing being measured.
The SOQL limit counts operations. This one counts time spent computing on the app server, and it's the only governor limit where that distinction matters: database execution time — the actual work SOQL, SOSL, and DML do on the database tier — does not count against it, and neither does time spent waiting on an HTTP callout. A query that takes 400ms to execute costs you a slot against the 100-query limit, but effectively nothing against the CPU budget. A callout that hangs for 8 seconds waiting on a slow endpoint costs nothing here either — that's what the separate callout timeout is for. What's left, by elimination, is your code: loops, branching, string work, serialization, formula-like logic re-implemented in Apex.
The other thing that makes this limit different: it's genuinely shared, no exceptions. SOQL and DML limits give certified managed packages their own separate allocation. CPU time does not — package code executing inside your transaction burns the same 10,000ms budget as your own triggers and Flows. If a transaction touches your trigger, a cascaded trigger on a related object, three record-triggered Flows, and a managed package's trigger handler, all five are drawing from one pool. The exception surfaces wherever the 10,001st millisecond happens to land — which is frequently not where the time was actually spent.
The usual suspects
1. Nested loops over trigger data
The classic shape: a loop over trigger records containing a loop over some related list, matching records by comparing fields instead of looking them up. Fine at 5 records. At 200 records against a 300-item related list, it's tens of thousands of comparisons — still no query, still no DML, just raw iteration cost that adds up.
// O(n × m) — 200 opportunities × 300 line items = 60,000 comparisons
for (Opportunity opp : opportunities) {
for (OpportunityLineItem li : allLineItems) {
if (li.OpportunityId == opp.Id) {
opp.Total_Weighted__c += li.TotalPrice * opp.Probability;
}
}
}The fix is the same map-based shape that fixes SOQL-in-a-loop: build a Map<Id, List<OpportunityLineItem>> keyed by OpportunityId once, then look up per opportunity — O(n + m) instead of O(n × m).
Map<Id, List<OpportunityLineItem>> byOpp = new Map<Id, List<OpportunityLineItem>>();
for (OpportunityLineItem li : allLineItems) {
if (!byOpp.containsKey(li.OpportunityId)) {
byOpp.put(li.OpportunityId, new List<OpportunityLineItem>());
}
byOpp.get(li.OpportunityId).add(li);
}
for (Opportunity opp : opportunities) {
if (!byOpp.containsKey(opp.Id)) continue;
for (OpportunityLineItem li : byOpp.get(opp.Id)) {
opp.Total_Weighted__c += li.TotalPrice * opp.Probability;
}
}This is exactly the same instinct as the collect-query-map pattern for SOQL — it just applies to in-memory matching instead of database lookups.
2. Trigger recursion
A trigger updates records of the same object it fired on. That update fires the trigger again. Without a guard, each re-entry re-runs the entire handler stack — including whatever expensive work happened the first time. Ten records that recurse three levels deep isn't 3x the work, it's closer to 3x the work at every level that recursion touches, compounding with anything else in the transaction. The fix is a recursion guard — a static boolean or counter checked at the top of the handler — or, more durably, a trigger framework that tracks "already processed this record in this transaction" centrally instead of per-handler.
3. Repeated expensive calls inside loops
Some Apex calls are cheap once and expensive per-iteration. The common ones:
// Schema.getGlobalDescribe() rebuilds a map of every object in the org.
// Calling it per record instead of once is a self-inflicted CPU tax.
for (Account a : accounts) {
Map<String, Schema.SObjectType> gd = Schema.getGlobalDescribe(); // move this out
if (gd.containsKey('Custom_Object__c')) { ... }
}
// String concatenation in a loop reallocates on every += for large N.
String csv = '';
for (Account a : accounts) {
csv += a.Name + ','; // fine for 20, wasteful for 20,000
}
// String.join(names, ',') instead
// JSON.serialize/deserialize of large payloads per record adds up fast —
// batch it outside the loop where the shape allows.The fix for the describe call is a static, populated once per transaction: private static Map<String, Schema.SObjectType> gd = Schema.getGlobalDescribe();. For string building at scale, use String.join(). For JSON work, serialize the collection once rather than per record when the shape allows it.
4. Death by a thousand Flows
Record-triggered Flows execute as part of the same transaction and the same CPU budget as Apex. Each element in a Flow — decision, loop, assignment, DML — has an execution cost. A single Flow on an object is usually invisible. A trigger plus three record-triggered Flows on the same object, each individually reasonable, can collectively exceed the budget — and because Flow execution doesn't show up in Apex code review, it's the CPU consumer most likely to be invisible until the limit is hit in production.
Diagnosis: checkpoints, not guessing
There's no real profiler for production Apex. The toolkit is smaller than you'd expect, but it works: Limits.getCpuTime() returns cumulative CPU milliseconds used so far in the transaction. Bracket suspect blocks with it and log the delta.
Integer before = Limits.getCpuTime();
enrichOpportunitiesWithLineItems(opportunities);
Integer after = Limits.getCpuTime();
System.debug('enrichOpportunitiesWithLineItems cost: ' + (after - before) + 'ms');Sprinkle these around the handler chain and the numbers tell you which block to fix — no need to instrument the whole codebase, just the two or three blocks you suspect. Debug logs carry the same information passively: every log with the right level includes a LIMIT_USAGE_FOR_NS section reporting cumulative CPU time at the point the log was captured, alongside the other governor limits. Between explicit checkpoints and reading that section, that's roughly the whole diagnostic toolkit Salesforce gives you — there's no sampling profiler, no flame graph, no line-level attribution in production.
Fixes that actually apply here
The standard governor-limit advice — "move it to @future" — is usually wrong for SOQL-in-a-loop (it just moves the same per-record query to a bigger limit). For CPU time, moving genuinely heavy computation to an async context is a legitimate fix, not a dodge: Batchable and Queueable contexts get 60,000ms instead of 10,000ms, and if the work is actually O(n) computation that scales with data volume rather than a structural bug, giving it six times the budget and running it off the synchronous path is the correct move.
The other legitimate fix, less code-flashy but often the right call: do less. Filter earlier so the loop body runs over fewer records. Compute a value once and cache it in a transaction-scoped static instead of recomputing it per iteration. Push a comparison into the SOQL WHERE clause instead of pulling everything back and filtering in Apex. CPU time problems are sometimes genuinely about algorithmic complexity, but just as often they're about doing work that didn't need to happen at all.
Write the budget test before production does
Nimbus tracks governor limits per test locally, including CPU consumption via the same Limits.getCpuTime() API described above, and the full suite runs in seconds. That makes a performance regression test cheap enough to actually write and keep: insert 200 records, run the trigger stack between Test.startTest() and Test.stopTest(), assert the CPU delta stays under a budget.
@isTest
static void enrichmentStaysUnderCpuBudget() {
List<Opportunity> opps = TestFactory.opportunitiesWithLineItems(200);
Test.startTest();
Integer before = Limits.getCpuTime();
insert opps; // fires the full trigger + Flow stack
Integer used = Limits.getCpuTime() - before;
Test.stopTest();
System.assert(used < 3000,
'Opportunity trigger stack used ' + used + 'ms CPU for 200 records');
}One caveat worth stating plainly: local CPU numbers are indicative, not authoritative — the hardware running the test is not the Salesforce application server, so absolute millisecond values won't match production exactly. Treat the assertion as a trend detector, not a millisecond-faithful oracle. It won't tell you the exact production CPU cost, but it will catch the handler that quietly grew from 400ms to 4,000ms per chunk between two commits — which is the failure mode that actually reaches production, because nobody re-measures CPU time on every code review by hand.
Checklist
- 10,000ms sync, 60,000ms async — shared by the whole transaction, including managed packages.
- Database time and callout wait don't count. This limit is purely about computation on the app server.
- Nested loops and trigger recursion are the usual culprits — map-based matching and a recursion guard fix most cases.
- Checkpoint with
Limits.getCpuTime()instead of guessing which block is expensive. - Async is a legitimate fix here for genuinely heavy computation — unlike dodging a SOQL-in-loop bug.
- Write the 200-record CPU budget test between
startTestandstopTest.
See CPU consumption per test
Run your suite locally and get SOQL, DML, and CPU time for every test method — no org, no deploy, no debug logs to spelunk for a LIMIT_USAGE_FOR_NS block.