Salesforce development · Apex performance

Salesforce Apex CPU Time Limit Exceeded: A Developer’s Guide to Debugging

Trace the transaction, find the expensive work, and fix the cause—with practical Apex examples, debugging diagrams, and a release checklist.

A Salesforce save fails with System.LimitException: Apex CPU time limit exceeded. The highlighted line belongs to your trigger, so you start rewriting it. But that line may only be where the transaction finally exhausted its budget. Earlier Apex, record automation, and repeated updates may have consumed most of the available CPU.

The useful question is therefore: which work grows unexpectedly, and why does this transaction perform it? This guide builds a repeatable investigation using debug logs, CPU checkpoints, collection patterns, and realistic tests. The examples use standard Account and Contact objects; adapt them to your automation and security requirements.

Start with evidence. Capture the failing entry point, input size, running user, release, and automation path before changing code. A successful single-record save does not explain why a bulk operation fails.

1. Identify the transaction and its CPU budget

CPU time measures application processing, not the complete wall-clock duration of a request. Database execution and time waiting for an external callout are excluded. Apex processing around those operations, and automation invoked in the same transaction, still consume the shared budget.[1]

Check the execution context before comparing limits
Execution contextCPU allowanceImportant distinction
Ordinary synchronous Apex10,000 msShared across the transaction
Queueable, future, Batch execute60,000 msEach transaction has its own budget
Scheduled Apex10,000 msSynchronous limits apply
Bulk API and Bulk API 2.060,000 msSeparately documented CPU allocation

Scheduled execution does not automatically mean a larger CPU allowance. Likewise, identify the actual import API rather than treating every integration request alike. Check the current governor-limit and Bulk API documentation, then record Limits.getLimitCpuTime() in the transaction you are investigating.[2][3]

A long query may explain a slow screen without explaining a CPU exception. Separate database wait, application CPU, and overall response time when deciding what to optimize.

2. Reproduce the actual failure path

Reproduce the operation in a representative sandbox with the same entry point and relevant permissions. Record whether the failure comes from a UI save, API update, scheduled job, or asynchronous worker. Include record count, related-record volume, changed fields, and enabled automation versions.

Set a short-lived trace flag for the user that actually runs the failing transaction. Retrieve the matching debug log through Salesforce tools; an unrelated successful save provides a misleading baseline.[4] These CLI commands assume an authenticated sandbox alias. Replace both placeholders.[5][6]

Salesforce CLI · replace the org alias and log IDRead logsbash
sf apex list log --target-org CPU-SANDBOX
sf apex get log --target-org CPU-SANDBOX --log-id YOUR_LOG_ID

Compare a failing sample with a successful sample that differs in one meaningful variable. For example, keep the same operation and user while changing the number of related Contacts. This makes workload growth easier to isolate than comparing unrelated transactions.

Keep a compact reproduction record with the operation name, deployment identifier, log timestamp, and relationship counts. Separate sensitive payloads from the debugging summary. If a package was upgraded or a Flow version activated before failures began, include that change in the timeline. Correlation helps prioritize investigation, but does not establish which component caused the regression.

Use focused debug levels. Excessive logging obscures the signal and can produce truncated logs. Preserve the exception, execution context, and relevant automation events; remove temporary trace flags when the investigation ends.

3. Measure phases instead of guessing from one line

A log timeline helps locate repeated calls, expensive paths, and automation invoked by DML. The Apex Log Analyzer provides timeline and call-tree views.[7] Treat displayed durations as elapsed-time evidence, not exact CPU attribution. Add a few checkpoints around meaningful phases to measure CPU growth directly.

CpuProbe.cls · temporary diagnostic helperFull classapex
public with sharing class CpuProbe {
    public static Integer mark(String stage, Integer previousCpuMs) {
        Integer used = Limits.getCpuTime();
        System.debug(LoggingLevel.INFO,
            'CPU stage=' + stage +
            ' usedMs=' + used +
            ' deltaMs=' + (used - previousCpuMs) +
            ' limitMs=' + Limits.getLimitCpuTime());
        return used;
    }
}

Limits.getCpuTime() reports CPU already used; Limits.getLimitCpuTime() reports the applicable limit.[8] Place this usage fragment around existing work, rather than adding a debug statement for every record.

Usage fragment · place around your existing phasesFragmentapex
Integer checkpoint = Limits.getCpuTime();

// Existing query and mapping phase runs here.
checkpoint = CpuProbe.mark('mapping complete', checkpoint);

// Existing DML phase runs here.
checkpoint = CpuProbe.mark('save complete', checkpoint);

A jump around a save includes work triggered by that save. Investigate its children before blaming the DML statement. Checkpoint deltas also include instrumentation overhead, and a failing transaction may never reach its final checkpoint.

Measure the phase again after one targeted change. Keep the checkpoints in comparable positions so that moving work outside the measured interval cannot masquerade as an improvement. Record the final transaction CPU as well as the local delta. A faster mapping function provides little relief if subsequent automation performs the same work again.

Follow the phase with unexpected CPU growth
Where does the CPU reading increase?
Before the save
Inspect mapping, repeated scans, and serialization
Across the save
Trace triggers, Flows, and repeated updates
Inside a background job
Inspect job scope and growing related data

4. Replace repeated matching with one grouping pass

Consider a handler that scans every Contact for every Account. With already loaded accounts and contacts, this fragment repeatedly performs the same matching work. The relevant fields must have been loaded.

Before · repeated full-list matchingAntipatternapex
// Assumes accounts and contacts are already loaded.
for (Account accountRow : accounts) {
    List<Contact> related = new List<Contact>();
    for (Contact contactRow : contacts) {
        if (contactRow.AccountId == accountRow.Id) {
            related.add(contactRow);
        }
    }
    // Existing per-account logic consumes related here.
}

Group Contacts once by AccountId, then look up each Account’s bucket. This helper contains no queries or DML and deliberately ignores null entries and Contacts without an Account.

ContactGrouping.cls · group once without database operationsFull classapex
public with sharing class ContactGrouping {
    public static Map<Id, List<Contact>> byAccount(List<Contact> contacts) {
        Map<Id, List<Contact>> grouped = new Map<Id, List<Contact>>();
        if (contacts == null) {
            return grouped;
        }

        for (Contact row : contacts) {
            if (row == null || row.AccountId == null) {
                continue;
            }
            List<Contact> bucket = grouped.get(row.AccountId);
            if (bucket == null) {
                bucket = new List<Contact>();
                grouped.put(row.AccountId, bucket);
            }
            bucket.add(row);
        }
        return grouped;
    }
}

After · reuse the grouped recordsFragmentapex
// Assumes accounts and contacts are already loaded.
Map<Id, List<Contact>> grouped = ContactGrouping.byAccount(contacts);
for (Account accountRow : accounts) {
    List<Contact> related = grouped.get(accountRow.Id);
    if (related == null) {
        related = new List<Contact>();
    }
    // Existing per-account logic consumes related here.
}

The original matching step grows with Accounts multiplied by Contacts. Grouping needs one pass through Contacts, followed by lookups and the actual per-account work. This removes unnecessary comparisons; it does not make expensive business logic inside each bucket disappear.

For illustration, matching 100 Accounts against 1,000 Contacts examines 100,000 pairs in the original loop. Grouping visits 1,000 Contacts once before the Account lookups. These are operation counts, not benchmark timings or guaranteed speedups. The actual benefit depends on the surrounding work, and the same records must still receive the correct business processing.

Not every nested loop is a defect. Iterating each parent’s own children can already be appropriate. Look for repeated traversal of unrelated records, duplicate calculations, repeated JSON conversion, and helpers invoked once per record that secretly repeat collection-wide work.

Maps also consume heap. Keep the required records and fields bounded, measure representative data, and preserve the original handling of missing relationships. An optimization that silently drops valid records is a correctness regression.

5. Retrieve only the result the business needs

Sometimes the expensive loop exists because the query returns much more information than the operation requires. If the requirement is a Contact count per Account, loading every Contact and counting in Apex is unnecessary. A grouped aggregate query expresses that requirement directly.[1]

ContactCountService.cls · count visible Contacts by AccountFull classapex
public with sharing class ContactCountService {
    public static Map<Id, Integer> byAccount(Set<Id> accountIds) {
        Map<Id, Integer> counts = new Map<Id, Integer>();
        if (accountIds == null || accountIds.isEmpty()) {
            return counts;
        }
        for (Id accountId : accountIds) {
            counts.put(accountId, 0);
        }

        for (AggregateResult row : [
            SELECT AccountId, COUNT(Id) contactCount
            FROM Contact
            WHERE AccountId IN :accountIds
            WITH USER_MODE
            GROUP BY AccountId
        ]) {
            counts.put(
                (Id) row.get('AccountId'),
                (Integer) row.get('contactCount')
            );
        }
        return counts;
    }
}

The helper initializes requested Accounts to zero, then replaces counts for matching groups. WITH USER_MODE preserves user-mode query access checks; the result reflects visible records, and missing required permissions can cause an exception.[9] Confirm that this matches the feature’s intended security model.

Use this pattern only when counts are sufficient. It cannot replace logic that genuinely needs individual Contacts. Aggregate queries still have limits; a smaller result does not make the underlying workload unlimited. Restrict the input Account set and test large related-record volumes.

Also examine query placement. A helper called repeatedly from a loop may repeat queries even when the query is not visibly inside the caller’s loop. Inspect the complete call chain.

Clarify what zero means before presenting aggregate results. No matching visible Contacts is different from a query that failed because access was denied. Do not catch a permissions error and convert it into an apparently valid zero count. Keep reporting semantics consistent with the feature’s access rules.

6. Trace automation re-entry and unnecessary updates

An efficient Apex helper can still fail inside an expensive automation chain. Document which triggers and Flows execute, which related records they update, and which updates invoke the original logic again. Salesforce’s record-triggered automation guidance emphasizes the combined automation design.[10]

Start with precise entry criteria. This fragment belongs inside an update-trigger context and selects only Contacts whose Email changed. Pass the collected IDs once to your existing bulk service.

Update-trigger context only · collect relevant record IDsFragmentapex
Set<Id> changedIds = new Set<Id>();
for (Contact row : Trigger.new) {
    Contact previous = Trigger.oldMap.get(row.Id);
    if (row.Email != previous.Email) {
        changedIds.add(row.Id);
    }
}
// If nonempty, pass changedIds once to your existing bulk service.

A changed-field check reduces irrelevant work, but it is not a complete recursion strategy. Related automation can legitimately change the field again. Design idempotent actions around record identity and business intent, and avoid a single static Boolean that suppresses all later processing indiscriminately.

Write down the expected update sequence before editing it. For example, a Contact update may refresh an Account summary, which then updates Contacts again. Identify the exact condition that should end that sequence. Test both the first legitimate update and a later legitimate update; a guard that only passes the first test can hide lost work.

For assignments to the triggering record, consider a before-save Flow when its capabilities fit the requirement. Avoid an extra update solely to set those same-record values.[11] Validate the complete order of execution after any change; reducing CPU must preserve validation and downstream behavior.

7. Move work asynchronously only when timing allows

Queueable or Batch Apex can separate work into new transactions, but moving a bad algorithm does not repair it. First decide whether the business result must be available when the original save completes.

Choose execution from the required business outcome
What must finish with the original save?
Required validation or result
Keep necessary work synchronous and optimize it
Deferrable, bounded follow-up
Evaluate Queueable and visible job status
Large partitionable workload
Evaluate Batch and measured scope sizes

Asynchronous processing has finite capacity and no guaranteed immediate execution. Account for queue delay, failures, retries, and duplicate requests.[12] Choose the mechanism and transaction boundaries deliberately; avoid creating one job per record.[13]

Re-query records when current state matters, define what happens if they changed or disappeared, and expose completion to the user or integration. Successfully enqueuing work is not proof that the business operation completed.

For partitioned work, select a scope using the expensive relationships, not just the parent count. One Account with thousands of related records can cost more than many ordinary Accounts. Measure completion rate and queue age after changing scope. More, smaller jobs can reduce CPU per transaction while increasing scheduling overhead and total completion time.

8. Test behavior and representative workload growth

This focused test checks grouping across 200 in-memory Contacts, plus null and unlinked entries. The synthetic Account IDs are test values; no database records are created.

ContactGroupingTest.cls · isolated behavior testTest classapex
@IsTest
private class ContactGroupingTest {
    @IsTest
    static void groupsBulkInputAndSkipsUnlinkedEntries() {
        Id firstAccount = '001000000000001';
        Id secondAccount = '001000000000002';
        List<Contact> contacts = new List<Contact>();
        for (Integer i = 0; i < 200; i++) {
            contacts.add(new Contact(
                LastName = 'Example ' + i,
                AccountId = Math.mod(i, 2) == 0
                    ? firstAccount : secondAccount
            ));
        }
        contacts.add(new Contact(LastName = 'Unlinked'));
        contacts.add(null);

        Test.startTest();
        Map<Id, List<Contact>> grouped = ContactGrouping.byAccount(contacts);
        Test.stopTest();

        System.assertEquals(2, grouped.size());
        System.assertEquals(100, grouped.get(firstAccount).size());
        System.assertEquals(100, grouped.get(secondAccount).size());
        for (Id accountId : grouped.keySet()) {
            for (Contact row : grouped.get(accountId)) {
                System.assertEquals(accountId, row.AccountId);
            }
        }
    }

    @IsTest
    static void acceptsEmptyAndNullInput() {
        System.assertEquals(0,
            ContactGrouping.byAccount(new List<Contact>()).size());
        System.assertEquals(0, ContactGrouping.byAccount(null).size());
    }
}

Test.startTest() provides a fresh test limit context; it is not a production escape hatch. Salesforce recommends testing bulk behavior and meaningful positive and negative cases.[14]

The helper test does not exercise your real triggers, Flows, permissions, or integrations. Add transaction-level tests with representative automation and data distribution. Include empty input, mixed relationships, repeated updates, and a parent with unusually many children.

Define acceptance checks before optimization: which records change, which records remain unchanged, and which errors remain visible. Compare those outputs before and after the patch. A test that merely finishes without an exception can pass even when an early return skipped the important work. Include the user’s observable result in the verification.

Compare CPU readings across repeated runs using equivalent input and debug settings. Avoid brittle assertions against one exact millisecond measurement. Verify outputs and track headroom separately so that timing noise does not obscure a genuine functional failure.

9. Validate the fix and monitor the business result

Deploy the smallest change supported by the evidence. Record the original workload, the changed phase, the measured CPU difference, and the business results that must remain correct. Repeat the original failing operation with representative bulk input before declaring success.

Do not rely on catching the governor-limit exception; these exceptions cannot be handled as ordinary recoverable errors.[15] Likewise, silently returning when CPU approaches the limit can leave required work unfinished. Smaller batches may reduce immediate pressure, but investigate why the workload grows.

Monitor failures, transaction CPU, input volume, and completion of deferred work. Set thresholds from observed workload and business tolerance. Give recurring failures a named owner and include enough context to reproduce them without collecting unnecessary record contents.

If the expensive path belongs to a managed package, prepare a focused support case with the reproducible operation, package version, workload size, and relevant log evidence. Explain which phase grows and which behavior must be preserved. This gives the vendor a concrete investigation target without claiming that an inaccessible implementation is definitely at fault.