Queueable Apex: Multi-Step Async Workflows With Chaining
Pass complex sObject state between async steps, reset governor limits per job, and track progress via AsyncApexJob -- a Queueable chaining guide for Salesforce.
30 Jun 2026, 02:06 UTC

The problem: @future can't carry complex state
When a trigger or process needs to run asynchronous work after a record commit, @future forces you to convert everything to primitives. That means losing sObject references, collections, or custom Apex types -- and you're stuck with one asynchronous hop. If your workflow spans update, callout, and email, you end up with a fragile chain of future calls that each share the same governor-limit bucket.
Why Queueable is a better fit
- Rich payload: the Queueable instance itself is serialized, so you can pass
List,Map, or any Apex type. - Fresh limits per step: each chained job runs in a new transaction with its own governor-limit bucket.
- Traceability:
System.enqueueJobreturns a job ID you can query onAsyncApexJobfor status, errors, and progress. - Post-commit guarantee: the job is only enqueued after the originating transaction commits; a rollback means the async work never runs -- ideal for side-effects like callouts or emails.
Chaining jobs while preserving state
Inside execute(QueueableContext ctx) you simply call System.enqueueJob(nextJob). The next job receives a brand-new QueueableContext and can read any fields you stored on the previous instance. Because each link runs sequentially, you get deterministic ordering without managing callbacks.
Worked example: update accounts then send a summary email
Run the snippet below in the Developer Console (Anonymous Apex) or from a test class wrapped in Test.startTest()/stopTest(). You need 'Author Apex' and 'View Setup' permissions.
public class AccountUpdater implements Queueable { List accounts; public AccountUpdater(List accs) { this.accounts = accs; } public void execute(QueueableContext ctx) { for (Account a : accounts) a.Description = 'Processed ' + DateTime.now(); update accounts; System.enqueueJob(new EmailNotifier(accounts)); } } public class EmailNotifier implements Queueable { List accounts; public EmailNotifier(List accs) { this.accounts = accs; } public void execute(QueueableContext ctx) { Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); mail.setToAddresses(new String[] {'ops@example.com'}); mail.setSubject('Accounts updated'); mail.setPlainTextBody('Updated ' + accounts.size() + ' accounts.'); Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail }); } } // Enqueue the first job List batch = [SELECT Id FROM Account WHERE LastModifiedDate = LAST_N_DAYS:1 LIMIT 200]; Id jobId = System.enqueueJob(new AccountUpdater(batch)); System.debug('Enqueued job Id: ' + jobId);After the script finishes, verify the chain:
AsyncApexJob job = [SELECT Id, Status, JobItemsProcessed, TotalJobItems, NumberOfErrors, ExtendedStatus FROM AsyncApexJob WHERE Id = :jobId]; System.debug('Status: ' + job.Status + ', Errors: ' + job.NumberOfErrors);Expect Status = 'Completed' and NumberOfErrors = 0. If the email step fails, the second job will show an error while the first remains successful.
Monitoring and governor-limit awareness
| Limit | Value (Enterprise/Unlimited) | How to check |
|---|---|---|
| Daily async executions | 250000 per 24h | Limits.getAsyncApexJobs() or Setup to Async Apex Jobs |
| Concurrent queueable jobs | 50 | Same UI; exceeding throws LimitException |
| Serialized instance heap | 6MB | Call Limits.getHeapSize() before enqueueJob |
Because each chained job consumes one execution from the daily pool, a long chain (hundreds of steps) can exhaust the limit quickly. For high-volume recurring work (>50k records per run) consider Batch Apex instead.
Trade-offs and practical limits
- No built-in retry: catch exceptions inside execute and re-enqueue with exponential back-off if you need resilience.
- Sequential chaining: jobs run one after another. For parallelism, enqueue multiple independent jobs from a single parent.
- Sharing model: with API v58+, a Queueable inherits the sharing context of the enqueuing code when that code runs inherited sharing; otherwise defaults to without sharing unless the class declares with sharing.
- Scheduling: Queueable cannot be scheduled directly. Wrap it in a Schedulable class that calls System.enqueueJob.
Quick verification checklist
- Create the Queueable classes in your org (API v58+).
- Run the anonymous snippet above with a small data set.
- Query AsyncApexJob for the returned job ID and confirm Status = 'Completed'.
- Check Limits.getAsyncApexJobs() before and after to see the consumption increase by two (one per step).
- Validate heap usage with Limits.getHeapSize() right before enqueueJob to stay under 6MB.
If all checks pass, you have a reliable, observable async pipeline that respects governor limits and carries complex state across steps.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.