Skip to main content

Breaking the CPU Time Limit: The Invocable-to-Queueable Split Pattern

The synchronous CPU time limit in Salesforce is 10,000 milliseconds (10 seconds), or 15,000 ms in some contexts. When a Flow-invoked Apex operation triggers cascading automations -- additional Flows, validation rules, rollup calculations, and trigger re-execution -- that budget is consumed quickly. The solution is to split work across two execution contexts: synchronous for fast, user-facing operations, and asynchronous for heavy processing.

When CPU Time Becomes a Problem

A common scenario: cloning a parent record with many child records. The Invocable method creates the parent clone, then inserts 50+ child clones. Each child insert fires:

  • Before/after triggers on the child object
  • Record-triggered Flows on the child object
  • Validation rules
  • Roll-up summary recalculations on the parent
  • Cross-object formula re-evaluations

With 50 children, the cumulative CPU cost can easily exceed the limit. The user sees a "CPU time limit exceeded" error with no records created.

The Split Pattern

Divide the operation into two phases:

  1. Synchronous (Invocable): Create the parent clone and return its ID to the user immediately.
  2. Asynchronous (Queueable): Clone all child records in a separate transaction with its own 60-second CPU limit.

The Invocable Method

public class CloneRequestInvocable {

public class CloneRequest {
@InvocableVariable(required=true)
public Id sourceRecordId;

@InvocableVariable
public String newName;
}

public class CloneResult {
@InvocableVariable
public Id clonedRecordId;

@InvocableVariable
public Boolean success;
}

@InvocableMethod(label='Clone Request with Children'
description='Creates header clone synchronously, children async')
public static List<CloneResult> cloneRecords(List<CloneRequest> requests) {
List<CloneResult> results = new List<CloneResult>();

for (CloneRequest req : requests) {
CloneResult result = new CloneResult();
try {
// Phase 1: Clone the parent (lightweight, fast)
Request__c source = [
SELECT Id, Name, Account__c, Status__c, Type__c
FROM Request__c
WHERE Id = :req.sourceRecordId
];

Request__c clone = source.clone(false, true);
clone.Name = req.newName != null ? req.newName : source.Name + ' (Clone)';
clone.Status__c = 'Draft';
clone.Cloned_From__c = source.Id;
insert clone;

// Phase 2: Enqueue child cloning asynchronously
System.enqueueJob(new CloneChildrenJob(source.Id, clone.Id));

result.clonedRecordId = clone.Id;
result.success = true;
} catch (Exception e) {
result.success = false;
}
results.add(result);
}
return results;
}
}

The Queueable Job

public class CloneChildrenJob implements Queueable {
private Id sourceParentId;
private Id targetParentId;

public CloneChildrenJob(Id sourceParentId, Id targetParentId) {
this.sourceParentId = sourceParentId;
this.targetParentId = targetParentId;
}

public void execute(QueueableContext ctx) {
List<Line_Item__c> sourceLines = [
SELECT Id, Name, Product__c, Quantity__c, Unit_Price__c,
Description__c, Sort_Order__c
FROM Line_Item__c
WHERE Request__c = :sourceParentId
];

List<Line_Item__c> clonedLines = new List<Line_Item__c>();
for (Line_Item__c line : sourceLines) {
Line_Item__c clonedLine = line.clone(false, true);
clonedLine.Request__c = targetParentId;
clonedLine.Is_Clone__c = true; // Bypass flag
clonedLines.add(clonedLine);
}

insert clonedLines;
}
}

The ID Mapping Handoff

When child records reference other child records (self-referencing hierarchies or cross-references), you need an ID mapping between source and target:

// Inside the Queueable
Map<Id, Id> oldToNewId = new Map<Id, Id>();

insert clonedLines;

for (Integer i = 0; i < sourceLines.size(); i++) {
oldToNewId.put(sourceLines[i].Id, clonedLines[i].Id);
}

// Second pass: update cross-references using the map
for (Line_Item__c cloned : clonedLines) {
if (cloned.Related_Line__c != null && oldToNewId.containsKey(cloned.Related_Line__c)) {
cloned.Related_Line__c = oldToNewId.get(cloned.Related_Line__c);
}
}
update clonedLines;

Bypass Filter Pattern

Cloned child records will fire the same Flows and triggers as manually created records. If those automations perform expensive operations (notifications, integrations, additional cloning), they can cascade and defeat the purpose of the split.

Use a bypass flag field to short-circuit automations on cloned records:

// In the Queueable
clonedLine.Is_Clone__c = true;

In your Flow entry conditions or trigger handler:

// Trigger handler
if (record.Is_Clone__c) {
return; // Skip processing for cloned records
}

CPU Budget Comparison

ContextCPU LimitTypical Use
Synchronous (Invocable, Trigger)10,000 msUI-facing operations
Asynchronous (Queueable)60,000 msHeavy processing, bulk operations

By splitting the work, you get the responsiveness of synchronous execution for the user-facing step and the generous CPU budget of async for the heavy lifting.