A typical multi-step async workflow: process 100,000 records through five stages. Validate. Enrich from an external API. Apply business rules. Update related records. Send notification. Each stage by itself is too much for one transaction. The whole thing in one Apex method blows past every governor limit Salesforce has.
Queueable chaining is the standard pattern for stitching such workflows together. Each stage runs as its own Queueable. State passes between stages via constructor parameters. The framework handles the async transitions. Done right, the workflow is observable, retryable, and bounded by the platform's job queue limits rather than transaction limits.
Done wrong, the workflow loses state, retries the wrong work, or quietly drops records when a chain link fails. The difference is design discipline.
What chaining actually does
A Queueable can enqueue another Queueable from within its execute method:
public class StageOneQueueable implements Queueable {
private List<Id> recordIds;
public StageOneQueueable(List<Id> ids) {
this.recordIds = ids;
}
public void execute(QueueableContext qc) {
// ... do stage one work ...
// Hand off to stage two.
System.enqueueJob(new StageTwoQueueable(this.recordIds, otherState));
}
}
The framework guarantees that the second Queueable runs in a fresh transaction with fresh governor limits. The chain can continue indefinitely (subject to the per-org async job concurrency limits).
State that needs to persist across stages travels via the constructor of the next Queueable. The state must be Serializable (primitives, sObjects, lists/maps/sets of those, or classes that implement Serializable).
When chaining is the right tool
Three patterns where chaining fits cleanly:
1. Multi-stage workflows with logical separation. Stage 1 validates, Stage 2 enriches, Stage 3 commits. Each stage has its own concerns; chaining keeps them in separate classes.
2. Work that exceeds one transaction's governor limits. A single stage is fine; the total work is not. Split into chunks, chain through them.
3. Workflows that involve callouts plus DML. Apex restricts the mix of callouts and DML in a single transaction in specific ways. Chaining lets each step be cleanly one or the other.
When chaining is the wrong tool:
- Volumes above 50,000 records per stage. Use Batch Apex.
- Genuinely parallel work that does not need ordering. Use Queueables enqueued concurrently, not chained.
- Single-stage work. Just use one Queueable; chaining adds complexity without benefit.
Passing state safely
The constructor parameter is the only way state flows from one Queueable to the next. Three patterns work:
Pass the data directly. For small state (a few IDs, a few flags), put it in the next Queueable's constructor. Simple, no external dependency.
System.enqueueJob(new StageTwoQueueable(
recordIds, // the records still being processed
enrichmentResults, // results from stage one to pass forward
retryCount // attempt counter for backoff logic
));
Pass an ID to a record that holds state. For larger state (a 50,000-record list of mappings), store the state in a custom object or platform cache and pass only the record ID forward.
// Persist state record.
ProcessingState__c state = new ProcessingState__c(
RecordIds__c = JSON.serialize(recordIds),
Stage__c = 'EnrichmentComplete'
);
insert state;
// Pass only the state record's ID.
System.enqueueJob(new StageTwoQueueable(state.Id));
Pass a reference to platform cache. For state that should not persist on the database, use Cache.Org.put(...) with a session-scoped key. The next Queueable retrieves via Cache.Org.get(key).
For most cases, the first pattern (direct constructor parameters) is the right starting point. Move to a persisted-state pattern when the constructor data grows large or when state needs to survive a job failure.
Error handling and retry
Async jobs fail. Network blips during callouts, transient governor limit issues, downstream record locks. The chain needs a strategy for what happens when a stage errors out.
Three retry strategies:
Exponential backoff in the same chain. The failing stage catches the exception, enqueues itself with a retry count, and the next attempt uses a longer delay (via System.enqueueJob after a System.now() + delay wait, or via a Scheduled job).
public void execute(QueueableContext qc) {
try {
// ... stage logic ...
} catch (CalloutException e) {
if (this.retryCount < 3) {
System.enqueueJob(new ThisStage(this.recordIds, this.retryCount + 1));
} else {
logError(e, this.recordIds);
}
}
}
Dead-letter queue for unrecoverable failures. Records that fail all retries land in a custom DLQ object for manual review.
Job status tracking. A custom object captures the chain's state at each stage: which records succeeded, which failed, where the chain stopped. Used for operational visibility and retry-on-demand.
The right strategy depends on the business cost of a dropped record vs the engineering cost of retry infrastructure. Most production workflows use some combination of in-chain retries plus a DLQ for genuinely failed cases.
Observability
Async chains run invisibly. Operations teams need a way to see what is happening.
Two patterns:
AsyncApexJob queries. The platform tracks every async job in the AsyncApexJob table. Query for status, completion time, errors:
SELECT Id, JobType, Status, ApexClass.Name, ExtendedStatus,
CompletedDate, NumberOfErrors
FROM AsyncApexJob
WHERE CreatedDate = TODAY
AND ApexClass.Name LIKE '%Queueable%'
ORDER BY CreatedDate DESC
A dashboard against this query shows the chain's pulse. Good for dev tooling and operational monitoring.
Custom progress records. A persistent record per chain run tracks progress through stages. Fields like RecordsProcessed__c, RecordsFailed__c, LastStage__c. Updated by each Queueable. Queryable by operations dashboards.
Without one or both of these, async chain debugging becomes a black box. A failed nightly job produces no signal until users notice missing data.
Chain depth limits
Queueable chains have practical depth limits:
- The recursion depth limit (the depth at which a chained Queueable cannot enqueue another) varies by org but is typically around 5 in synchronous test contexts and effectively unlimited in async contexts.
- The per-org concurrent async job limit (50 by default, higher with adjustments).
- The 24-hour async job queue limit (typically 250K, varies by edition).
For workflows that need to process millions of records across many stages, Batch Apex is usually the better fit. Queueable chaining works for tens of thousands of records across five to ten stages. Above that, hybrid patterns (Batch that triggers Queueable, or Queueable that triggers Batch) become useful.
Testing chains
Test classes can enqueue Queueables but the chain does not naturally propagate during the test. The pattern that works:
@isTest
static void testStageOneEnqueuesStageTwo() {
Test.startTest();
System.enqueueJob(new StageOneQueueable(testIds));
Test.stopTest(); // Stage 1 executes here, enqueues Stage 2.
// After Test.stopTest, only Stage 1 has run.
// Stage 2 is enqueued but not executed.
// Verify Stage 1's behavior.
// Mock or simulate Stage 2 separately.
}
Each stage gets its own test. The chain transition (Stage N enqueuing Stage N+1) is verified by checking that the AsyncApexJob queue has the expected next job. The next stage's behavior is tested in isolation.
Common chaining mistakes
Five patterns Sapota has seen in audits:
- State lost between stages. Stage 1 computes results, stage 2 re-computes them because the chain forgot to pass them. Wasted work or, worse, inconsistent results.
- No retry logic. A transient failure in stage 3 stops the chain. The remaining records sit in limbo. Operations notices days later.
- Chain depth abuse. A chain that runs 20 stages because each stage is a tiny operation. The framework overhead dominates. Combine logically related steps into fewer, larger stages.
- Sync code mixed in. A "Queueable" that also does synchronous DML or callouts before enqueuing the next stage. Hits transaction limits.
- No observability. Operations cannot see the chain's progress. Debugging requires reading raw AsyncApexJob queries.
What good chaining looks like
A Salesforce org running healthy Queueable chains:
- Each stage is a separate class with a clear single responsibility.
- State passed via constructor for small data, via persisted records for large data.
- Every chain has retry logic with bounded attempts.
- Operations dashboard surfaces chain progress and recent failures.
- Tests cover each stage independently and verify the next stage gets enqueued.
Sapota's Salesforce team holds the Platform Developer I credential and designs Queueable chains as deliberate workflows with explicit state and retry contracts on production engagements. The investment pays back the first time a chain runs reliably for a week without manual intervention.
Designing multi-stage async workflows in Salesforce Apex? Sapota's Salesforce team handles Queueable chain design, state management, and operational observability on production engagements. Get in touch ->
See our full platform services for the stack we cover.