What You'll Learn
The four async Apex mechanisms and when each is the right tool, the governor limits specific to each type, Queueable chaining patterns, Batch Apex cursor and stateful patterns, and the common mistakes that lead to production failures.
Why Async Apex Exists
Synchronous Apex transactions have hard governor limits: 100 SOQL queries, 150 DML statements, 10 MB heap, 10 second CPU time. These limits prevent any single transaction from monopolising the multi-tenant infrastructure. Async Apex runs outside the synchronous transaction, with its own fresh set of limits, enabling long-running and high-volume operations that synchronous code cannot handle.
The four async mechanisms are: Future methods, Queueable Apex, Batch Apex, and Schedulable Apex. Each has a different use case, different limits, and different capabilities.
Future Methods: Simple Fire-and-Forget
Future methods are the simplest async mechanism — annotate a static method with @future and Salesforce runs it in a separate transaction after the current one commits. They accept only primitive parameters (no SObjects, no collections of SObjects) because the current transaction's heap is not available in the async context.
// Future method — simple async callout
@future(callout=true)
public static void syncToERP(Set<Id> accountIds) {
List<Account> accounts = [SELECT Id, Name, Phone
FROM Account WHERE Id IN :accountIds];
for (Account acct : accounts) {
// Make callout for each account
ERPService.syncAccount(acct);
}
}
// Limitations:
// - No SObject parameters (pass Ids, query inside)
// - Cannot monitor or chain
// - Max 50 future calls per transaction
// - No Stateful interfaceQueueable Apex: Chainable and Monitorable
Queueable Apex was introduced to address future method limitations. A class implementing Queueable can accept complex parameters (SObjects, custom objects), can chain to the next job in its execute() method, and returns a job ID that can be monitored via SOQL.
// Queueable with chaining
public class ProcessOrdersJob implements Queueable, Database.AllowsCallouts {
private List<Order__c> orders;
private Integer batchIndex;
public ProcessOrdersJob(List<Order__c> orders, Integer batchIndex) {
this.orders = orders;
this.batchIndex = batchIndex;
}
public void execute(QueueableContext ctx) {
// Process this batch of orders
Integer endIdx = Math.min(batchIndex + 50, orders.size());
processOrders(orders.subList(batchIndex, endIdx));
// Chain to next batch if more to process
if (endIdx < orders.size()) {
System.enqueueJob(new ProcessOrdersJob(orders, endIdx));
}
}
}
// Monitor via SOQL
AsyncApexJob job = [SELECT Id, Status, NumberOfErrors
FROM AsyncApexJob
WHERE Id = :jobId];Test.startTest() / Test.stopTest() to process the queue.
Batch Apex: High-Volume Record Processing
Batch Apex processes records in configurable chunks (default 200, max 2,000 per chunk). Salesforce calls execute() once per chunk with fresh governor limits for each chunk. A batch job can process tens of millions of records by breaking them into manageable pieces.
public class LeadScoreBatch implements Database.Batchable<SObject>,
Database.Stateful {
// Stateful: instance variables persist across execute() calls
public Integer processedCount = 0;
public Integer errorCount = 0;
// start(): returns a QueryLocator (max 50M records) or Iterable
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Email, LastActivityDate FROM Lead WHERE IsConverted = false'
);
}
// execute(): called once per chunk with fresh governor limits
public void execute(Database.BatchableContext bc, List<Lead> scope) {
List<Lead> toUpdate = new List<Lead>();
for (Lead l : scope) {
l.LeadScore__c = calculateScore(l);
toUpdate.add(l);
processedCount++;
}
try {
update toUpdate;
} catch (Exception e) {
errorCount++;
}
}
// finish(): called once after all chunks complete
public void finish(Database.BatchableContext bc) {
// Send completion notification
sendSummaryEmail(processedCount, errorCount);
}
}Database.AllowsCallouts. Even with that interface, callouts are limited to one per execute() chunk — you cannot make 200 callouts in a 200-record chunk. Design patterns that batch callout work separately from DML work.
Choosing the Right Pattern
Decision framework:
- Use @future: Simple fire-and-forget operations with primitive data, single callout, no chaining needed. Legacy code maintenance — prefer Queueable for new code.
- Use Queueable: Complex object parameters, callouts required, chaining to next job, need to monitor job ID. Best for workflows that need state passed between steps.
- Use Batch Apex: Processing more than a few thousand records, need stateful accumulation across chunks, need
finish()callback, record set fits a SOQL query. - Use Schedulable: Time-based triggers only — always delegates actual work to Batch or Queueable.
AsyncApexJob for any jobs with Status = 'Failed' in the past 24 hours and emails the admin team. Silent job failures are the most common undetected production issue in large orgs.
Key Takeaways
- Future methods: simple, limited to primitives, no chaining — best for legacy or simple fire-and-forget.
- Queueable: complex parameters, chainable, monitorable — the modern default for most async needs.
- Batch Apex: high-volume record processing in chunks, fresh governor limits per chunk, Stateful for cross-chunk accumulation.
- Schedulable: time trigger only — always delegates work to Batch or Queueable, never processes records directly.
- Queueable chaining limit is 50 in production — use Batch Apex for deeper processing chains.
- Monitor AsyncApexJob for failures proactively — silent job failures are the most common undetected production issue.
Check Your Understanding
1. A process needs to make an HTTP callout for each of 5,000 records. Which async mechanism is appropriate?
2. A Batch Apex job needs to count total records processed and errors across all chunks and report them in finish(). What interface is required?
3. A Queueable chain needs to process 60 levels of chained operations in production. What is the issue?
Discussion & Feedback