Loading

Salesforce Platform: Troubleshoot Apex CPU Time Limit Exceeded Errors

Julkaisupäivä: Mar 25, 2026
Kuvaus

The System.LimitException: Apex CPU time limit exceeded error occurs when a Salesforce transaction consumes more processing time on the application server than the platform allows. Salesforce enforces this governor limit to prevent any single transaction from monopolizing shared, multi-tenant resources.

  • Synchronous Limit: 10,000 milliseconds (10 seconds) for transactions initiated from the UI, Apex web services, or executeAnonymous.
  • Asynchronous Limit: 60,000 milliseconds (60 seconds) for transactions in asynchronous contexts like Batch Apex, Queueable Apex, @future methods, and scheduled jobs.


What Consumes CPU Time?

CPU time is the measure of time the server spends executing code and declarative automation.

  • Apex Code: All executed lines of your Apex classes, triggers, and controllers.
  • Declarative Automation: Logic executed by Flows (especially loops and decisions), Process Builders (which are notoriously inefficient), and Workflow Rules.
  • Formula Field Calculations: The server must spend CPU cycles to evaluate formula fields when they are accessed in code or automation.
  • Database Operation Overhead: While the database's own processing time is not counted, the CPU time used by Salesforce to prepare DML/SOQL calls and process the returned data is included.

What Does NOT Consume CPU Time?

  • Database Time: The time the database spends executing a query or saving records. This is tracked separately. A transaction can spend a long time on a slow query without hitting the CPU limit.
  • Callout Time: Time spent waiting for a response from an external system (@future, Queueable).
  • UI Rendering: Time spent by a user's browser rendering Lightning Web Components or Visualforce pages.
Ratkaisu

1: Debug Logs

Debug logs are the primary tool for a granular analysis of a transaction.

Setting Up Optimal Debug Logs

To capture the necessary detail without generating an excessively large log file:

  1. Navigate to Setup > Debug Logs.
  2. Create a Trace Flag for the user triggering the error.
  3. Create a new Debug Level with the following settings, which are optimized for performance analysis:
    • Database: INFO (Shows the SOQL query without the returned data, saving space).
    • Workflow: INFO
    • Validation: INFO
    • Callout: INFO
    • Apex Code: FINER or FINEST (Essential for method entry/exit).
    • Apex Profiling: FINEST (This is the most critical setting for CPU analysis).
    • System: DEBUG

Avoiding Log Truncation and Managing Limits

  • Debug Log Size Limit: A single debug log cannot exceed 20 MB. If it does, the log is truncated, cutting off the end. This is problematic because the most useful summary information, like CUMULATIVE_LIMIT_USAGE, is located at the very end.
  • Org Log Storage Limit: Your organization has a shared storage limit of 1,000 MB for all debug logs. Once this is full, no new logs can be saved until old ones are deleted.
  • Managing Logs: Regularly delete old logs from the Setup > Debug Logs page to stay under the org-wide limit. You can use the "Delete All" button or selectively delete logs. For a more targeted approach, you can programmatically identify and delete the largest logs using a SOQL query against the ApexLog object. You can select the rows for the largest logs and click the "Delete Rows" button to quickly free up the most space.
    SELECT Id, LogLength FROM ApexLog ORDER BY LogLength DESC
     

If your log is still being truncated even with optimized debug levels, your best strategy is to reduce the transaction's scope. For example, instead of running a batch update on 200 records, replicate the issue with only 10 records. This will generate a smaller, complete log that still reveals the performance bottleneck.

Manually Identifying CPU-Intensive Operations

After generating a log, open the raw file and search for these key entries:

  • CODE_UNIT_STARTED and CODE_UNIT_FINISHED: This appears at the start and end of every method, trigger, or Flow execution. To find out how long a specific method took to run, find its matching STARTED and FINISHED pair in the log. The difference in the timestamps between these two lines tells you the total duration of that method's execution.

  • LIMIT_USAGE_FOR_NS: One of the most important lines in the log for this issue are the LIMIT_USAGE_FOR_NS entries. These act as checkpoints, showing you how much of a specific limit (like CPU time) has been used up to that point.

...|CODE_UNIT_FINISHED|...|MyHelperClass.processAccounts|

...|CODE_UNIT_FINISHED|...|MyTriggerHandler.updateContacts|

... |LIMIT_USAGE_FOR_NS|... |CPU_TIME|8560|10000

  • CUMULATIVE_PROFILING: When profiling is set to FINEST, the log includes this summary. It often appears near the end of the log and provides a breakdown of the most time-consuming methods, making it an excellent starting point.

    CUMULATIVE_PROFILING|Apex|MyTriggerHandler.updateContacts:52|1|8321

 



2: Developer Console

The Developer Console provides a built-in, visual log analyzer that is useful for quick analysis.

Steps for Analysis

  1. Open the Developer Console.
  2. Go to the Logs tab and double-click the log you want to analyze.
  3. Go to Debug > Switch Perspective and select Analysis.
  4. In the bottom panel, navigate to the Executed Units tab.
  5. Sort the table by the Total Time (ms) column in descending order. The item at the top is your primary performance bottleneck.
    • Total Time: The time spent in the method and any other methods it called.
    • Self Time: The time spent only within the method itself. A high Self Time points to an inefficient loop or complex calculation within that specific method.
  6. Switch to the Timeline tab for a visual flame graph of the transaction. Look for the longest horizontal bars, as they represent the longest-running processes.

Limitations of the Developer Console

  • Performance: It can be very slow or even crash when trying to open large, multi-megabyte log files.
  • Limited Detail: While good for a high-level overview, it can sometimes be less effective than manual log inspection or specialized tools for digging into deeply nested code.


3: Apex Log Analyzer (VS Code Extension)

This free VS Code extension is arguably the most powerful tool for analyzing performance from debug logs.

Reference: Apex Log Analyzer on VS Code Marketplace

Steps for Analysis

  1. Install the Apex Log Analyzer extension in Visual Studio Code.

  2. In VS Code, open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P) and run SFDX: Get Apex Debug Logs.

  3. Select and download the log you want to analyze.

  4. With the log file open, run the command Apex Log Analyzer: Show Analysis.

  5. A new panel will open. Click on the Call Tree tab.

Key Features for CPU Analysis

  • Call Tree: This is the most valuable feature. It provides a hierarchical view of the entire transaction. You can expand the methods with the highest Total Duration to follow the "path of pain" down the execution stack until you find the method with a high Self Duration. This method is the root cause of the CPU consumption.

  • Timeline View: Offers a color-coded visualization of the transaction, making it easy to spot patterns like hundreds of repeated, small database calls.

Limitations

  • The tool is dependent on the quality of the debug log. If the log is truncated, the analysis will be incomplete.


4: Using the Limits Class in Apex Code

When a log is consistently truncated, you can't see the final CPU usage. The Limits class allows you to proactively log CPU consumption at various checkpoints within your code.

How to Implement It

Place System.debug() statements at key points in your transaction to output the current CPU time.

Apex

public class MyComplexProcessor {
    public void execute() {
        System.debug(LoggingLevel.WARN, '--- START of process. CPU Used: ' + Limits.getCpuTime() + 'ms');
        // First major operation
        this.processInputs();
        System.debug(LoggingLevel.WARN, '--- After processing inputs. CPU Used: ' + Limits.getCpuTime() + 'ms');
        // Second, potentially slow operation
        this.updateRelatedRecords();
        System.debug(LoggingLevel.WARN, '--- After updating records. CPU Used: ' + Limits.getCpuTime() + 'ms');
    }
    // ... other methods
}

Benefits and Best Practices

  • Benefit: It provides visibility into CPU usage even if the transaction fails and the log is truncated. You can see the last checkpoint that was successfully passed.

  • When to Use: Use this when dealing with extremely large transactions (e.g., complex batch jobs) where logs are consistently truncated.

  • Best Practice: Use a distinct LoggingLevel like WARN or ERROR. This allows you to set your debug log's Apex Code level to WARN to get a very small log containing only your custom checkpoint messages, making analysis trivial.

Limitations

  • It requires modifying your Apex code.

  • It only tells you the cumulative CPU time at a point; it doesn't break down the cause like a full debug log.

 


Examples and Best Practices

Example 1: Apex Class (Trigger Handler with Inefficient Lookups)

Context: An OpportunityLineItem trigger needs to validate that the product's Family is not on a "disallowed" list stored on the parent Account's Country__c record, which is a lookup to a custom Country_Setting__c object.

Inefficient Code: The code iterates through each line item and performs a separate SOQL query for each one to get the parent details.

Apex:

// Inefficient Trigger Handler
public class OliTriggerHandler {
    public static void onBeforeInsert(List<OpportunityLineItem> newOlis) {
        for (OpportunityLineItem oli : newOlis) {
            // SOQL query INSIDE the loop
            Opportunity opp = [SELECT Account.Country__r.Disallowed_Product_Families__c
                               FROM Opportunity
                               WHERE Id = :oli.OpportunityId];

            String disallowed = opp.Account.Country__r.Disallowed_Product_Families__c;
            Product2 prod = [SELECT Family FROM Product2 WHERE Id = :oli.Product2Id]; // Second SOQL in loop

            if (disallowed != null && disallowed.contains(prod.Family)) {
                oli.addError('This product family is not allowed for the account country.');
            }
        }
    }
}

Why it failed: With 200 line items, this would execute 400 SOQL queries, but more importantly, the repeated database calls and loop processing burned CPU time preparing and handling each query.

Optimized Code: The code is bulkified to use maps and single queries.

Apex:

// Optimized Trigger Handler
public class OliTriggerHandler {
    public static void onBeforeInsert(List<OpportunityLineItem> newOlis) {
        // Step 1: Collect all unique IDs needed
        Set<Id> oppIds = new Set<Id>();
        Set<Id> productIds = new Set<Id>();
        for (OpportunityLineItem oli : newOlis) {
            oppIds.add(oli.OpportunityId);
            productIds.add(oli.Product2Id);
        }

        // Step 2: Query all data once using maps
        Map<Id, Product2> productMap = new Map<Id, Product2>([SELECT Family FROM Product2 WHERE Id IN :productIds]);
        Map<Id, Opportunity> oppMap = new Map<Id, Opportunity>([
            SELECT Account.Country__r.Disallowed_Product_Families__c
            FROM Opportunity
            WHERE Id IN :oppIds
        ]);

        // Step 3: Process with fast, in-memory lookups
        for (OpportunityLineItem oli : newOlis) {
            Opportunity opp = oppMap.get(oli.OpportunityId);
            Product2 prod = productMap.get(oli.Product2Id);
            String disallowed = opp.Account.Country__r.Disallowed_Product_Families__c;
            if (disallowed != null && prod != null && disallowed.contains(prod.Family)) {
                oli.addError('This product family is not allowed for the account country.');
            }
        }
    }
}

Example 2: Batch Apex (Inefficient finish Method)

Context: A batch job processes all Case records. The finish method must send a summary email that includes a count of cases processed for each RecordType.

Inefficient Code: The finish method re-queries all the cases that were just processed to perform its aggregation.

Apex:

global class ProcessCasesBatch implements Database.Batchable<sObject> {
    global Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator([SELECT RecordTypeId FROM Case]);
    }

    global void execute(Database.BatchableContext bc, List<Case> scope) {
        // Processing logic for each case...
    }

    global void finish(Database.BatchableContext bc) {
        // Re-queries ALL cases again. Very slow and CPU intensive for large volumes.
        Map<Id, Integer> counts = new Map<Id, Integer>();
        for (Case c : [SELECT RecordTypeId FROM Case WHERE Status = 'Processed']) {
            Integer currentCount = counts.get(c.RecordTypeId);
            if (currentCount == null) currentCount = 0;
            counts.put(c.RecordTypeId, currentCount + 1);
        }
        // Send email with counts...
    }
}

Why it failed: The finish method's SOQL query could return millions of records, and iterating over them to build the map is extremely CPU-intensive. The transaction times out.

Optimized Code: Use Database.Stateful to aggregate results incrementally across execute batches.

Apex:

global class ProcessCasesBatch implements Database.Batchable<sObject>, Database.Stateful {
    // Stateful property to hold counts across batches
    global Map<Id, Integer> countsByRecordType;

    global ProcessCasesBatch() {
        countsByRecordType = new Map<Id, Integer>();
    }

    global Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator([SELECT RecordTypeId FROM Case]);
    }

global void execute(Database.BatchableContext bc, List<Case> scope) {
        // Processing logic...
        // Aggregate counts within each execute method
        for (Case c : scope) {
            Integer currentCount = countsByRecordType.get(c.RecordTypeId);
            if (currentCount == null) currentCount = 0;
            countsByRecordType.put(c.RecordTypeId, currentCount + 1);
        }
    }

    global void finish(Database.BatchableContext bc) {
        // The map is already built. No SOQL needed.
        // Send email with countsByRecordType...
        System.debug('Final counts: ' + countsByRecordType);
    }
}

Example 3: Flow (Invoking Apex Inside a Loop)

Context: A record-triggered Flow on Account runs when a custom Tier__c field changes. The Flow must loop through all related Contact records and call an Apex Action to determine if each Contact is eligible for a new marketing campaign.

Inefficient Flow :

  1. Start: Record-triggered Flow on Account update.

  2. Get Records: Gets all related Contact records.

  3. Loop: Iterates through the collection of Contacts.

  4. Action (Inside Loop): Calls an Apex Action named Check Eligibility, passing the current Contact from the loop.

Why it failed: Calling an Apex Action has significant CPU overhead. If an Account has 150 contacts, the Flow invokes the Apex Action 150 times. The cumulative CPU cost of initializing the Apex and performing the check repeatedly causes a timeout, especially during a mass update of Accounts.

Optimized Flow & Apex: The Apex Action is modified to be bulk-safe, and the Flow is redesigned to call it only once.

Bulkified Apex Action: The method is changed to accept a List of Contact IDs and return a Map of results.

Apex:

public class MarketingEligibilityCheck {
    @InvocableMethod(label='Check Contact Eligibility (Bulk)')
    public static List<Result> checkContacts(List<Request> requests) {
        Set<Id> contactIds = new Set<Id>();
        for(Request req : requests) {
            contactIds.add(req.contactId);
        }
        // Complex logic now runs on the entire set at once
        Map<Id, Contact> contactsWithData = new Map<Id, Contact>([
            SELECT Id, DoNotCall, HasOptedOutOfEmail FROM Contact WHERE Id IN :contactIds
        ]);
        List<Result> results = new List<Result>();
        for (Request req : requests) {
            Contact c = contactsWithData.get(req.contactId);
            Result res = new Result();
            res.contactId = c.Id;
            res.isEligible = (!c.DoNotCall && !c.HasOptedOutOfEmail); // Example logic
            results.add(res);
        }
        return results;
    }

    public class Request {
        @InvocableVariable(required=true)
        public Id contactId;
    }

    public class Result {
        @InvocableVariable
        public Id contactId;
        @InvocableVariable
        public Boolean isEligible;
    }
}

Optimized Flow Design:

  1. Start: Same.

  2. Get Records: Same.

  3. Loop: Iterates through the Contact collection.

  4. Assignment (Inside Loop): Adds the Id of the current Contact to a collection variable (e.g., contactIdCollection).

  5. Action (After Loop): Calls the bulkified Check Eligibility Apex Action once, passing the entire contactIdCollection.

  6. The Flow can then loop through the results from the action if further logic is needed.

Knowledge-artikkelin numero

005132111

 
Ladataan
Salesforce Help | Article