Queueable Apex Job Chaining: A Practical Pattern for Large Data Processing
Queueable Apex chaining splits large data processing into a sequence of jobs, each with fresh governor limits. A worked example, the limits that matter, and the mistakes that break chains.
04 Nov 2025, 05:07 UTC

When a synchronous Apex transaction runs out of governor limits — too many SOQL queries, too much CPU time, too many DML rows — the usual fix is to move the work into the background. Queueable Apex is the tool for that, and its chaining feature lets you split a big job into a sequence of smaller jobs, each with a fresh set of limits. The pattern: process one batch of records, hand the remaining work to a new job, repeat until nothing is left.
This article explains how chaining works, shows a complete example you can adapt, and covers the limits and mistakes that bite in production.
Why Queueable instead of @future
A @future method only accepts primitives and collections of primitives. A Queueable class implements the Queueable interface with an execute(QueueableContext) method, and its constructor can accept anything — lists of sObjects, custom wrapper objects, maps of state. That makes it practical to pass complex context from one job to the next. You launch it with System.enqueueJob(), which returns a job ID you can track in the AsyncApexJob object (visible under Setup > Apex Jobs).
Each enqueued job runs in its own transaction with a fresh allocation of governor limits. That is the whole point of chaining: instead of one transaction that must finish everything within one limit budget, you get a sequence of transactions, each with its own budget.
A worked example: processing accounts in batches
This class accepts a list of Account IDs, processes a fixed slice of them, and chains a new job for the remainder.
public class AccountSyncJob implements Queueable {
private List<Id> remainingIds;
private static final Integer BATCH_SIZE = 200;
public AccountSyncJob(List<Id> ids) {
this.remainingIds = ids;
}
public void execute(QueueableContext ctx) {
List<Id> batch = new List<Id>();
while (!remainingIds.isEmpty() && batch.size() < BATCH_SIZE) {
batch.add(remainingIds.remove(0));
}
List<Account> accts = [
SELECT Id, Sync_Status__c FROM Account WHERE Id IN :batch
];
for (Account a : accts) {
a.Sync_Status__c = 'Processed';
}
update accts;
if (!remainingIds.isEmpty()) {
System.enqueueJob(new AccountSyncJob(remainingIds));
}
}
}Launch it once with the full ID list — from a trigger handler, a REST endpoint, or Anonymous Apex in the Developer Console:
List<Id> ids = new List<Id>(new Map<Id, Account>(
[SELECT Id FROM Account WHERE Sync_Status__c = null]
).keySet());
Id jobId = System.enqueueJob(new AccountSyncJob(ids));The chain terminates itself: when remainingIds is empty, no new job is enqueued. Design chains to end on data completion like this, not on a fixed counter — chaining depth is restricted in some org types (Developer Edition orgs have historically capped chain depth at 5), while production orgs allow one chained job per executing job without a practical depth cap. Confirm the current behavior for your edition in the Salesforce governor limits documentation before relying on deep chains.
Limits that shape the design
- One chain per job. An executing Queueable job can enqueue one child job. If you need fan-out, enqueue multiple jobs from the original caller, not from inside the chain.
- 50 jobs per transaction. You can add at most 50 Queueable jobs in a single synchronous transaction. Enqueueing inside a loop over trigger records is the classic way to hit this — collect the work first, enqueue once.
- Daily async allocation. Queueable executions count against your org's daily asynchronous Apex limit (which scales with license count). Jobs are queued, not immediate, so this pattern is wrong for latency-critical work.
- No guaranteed ordering or timing. Chained jobs usually run promptly, but the platform makes no timing promise.
Common mistakes
Silent chain breaks. An unhandled exception marks the AsyncApexJob as Failed and the chain simply stops — no retry, no alert. Wrap the body of execute() in try/catch and log failures (for example, to a custom error-log object), or attach a TransactionFinalizer via System.attachFinalizer() to react to job success or failure. Note that a finalizer cannot re-enqueue the same job, so use it for alerting and compensation, not automatic retry loops.
Passing stale sObjects. If you pass queried records into the next job, they reflect the data as of the original query. Re-query by ID inside each job, as the example does, so every transaction sees current data and respects its own query limits.
Enqueueing from a trigger per record. Batch the IDs into a single job per trigger execution.
Testing and verification
In an Apex test, code between Test.startTest() and Test.stopTest() causes queued jobs to execute synchronously when stopTest() is called — including chained jobs enqueued during that execution:
@IsTest
static void testChain() {
List<Account> accts = new List<Account>();
for (Integer i = 0; i < 250; i++) {
accts.add(new Account(Name = 'T' + i));
}
insert accts;
Test.startTest();
System.enqueueJob(new AccountSyncJob(
new List<Id>(new Map<Id, Account>(accts).keySet())));
Test.stopTest();
Integer done = [SELECT COUNT() FROM Account
WHERE Sync_Status__c = 'Processed'];
System.assertEquals(250, done);
}To verify in a sandbox: deploy the class, run the enqueue snippet in Anonymous Apex, then check Setup > Apex Jobs or query SELECT Status, JobItemsProcessed, ExtendedStatus FROM AsyncApexJob WHERE Id = :jobId. You should see one Completed row per chained batch. If you want to confirm the 50-job enqueue limit in your org, deliberately enqueue in a loop past 50 and observe the LimitException — do this only in a sandbox.
Exact numeric limits and chaining-depth rules vary by edition and release, so treat the numbers here as a starting point and check the current Apex governor limits documentation for your org.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.