Choosing between Queueable, Batch, @future and Scheduled Apex is one of the most common asynchronous Apex decisions developers face — and choosing wrong is one of the most common reasons production orgs hit AsyncException storms, stuck flex queues and silent data loss. This guide gives you the decision framework, the governor limit differences, and the failure modes of each async primitive, current to Summer ‘26 and API v67.0.
If you have not internalized the synchronous limits first, read the Apex Governor Limits guide — async limits only make sense relative to their synchronous counterparts.
The four async primitives at a glance
| Primitive | Built for | Job ID returned | Chaining | Complex params |
|---|---|---|---|---|
@future | Legacy fire-and-forget | No | No | No (primitives only) |
| Queueable | Single async unit of work | Yes | Yes (1 child per job) | Yes (objects, sObjects) |
| Batch | Large record volumes in chunks | Yes | Via finish() | Via constructor |
| Scheduled | Time-based execution | Yes | Starts other async | Via constructor |
The platform also queues every one of these through the same Apex flex queue and enforces a shared ceiling: 250,000 async executions per 24 hours (or 200 × user licenses, whichever is greater). Every primitive draws from that pool.
@future — why it’s legacy
@future methods accept only primitive parameters, return nothing, cannot be chained, and give you no job ID to monitor. When a @future call fails, you find out from an email — if at all.
public class AccountEnricher {
@future(callout=true)
public static void enrich(Set<Id> accountIds) {
// No job ID. No chaining. No retry hook.
// If this throws, the work is silently lost.
}
}
The two rules that still matter for @future in 2026:
- You cannot call a @future method from another @future method or from Batch Apex — both throw at runtime.
- Up to 50 @future invocations are allowed per transaction, and they are not guaranteed to execute in order.
There is no new code that should use @future. Every capability it has, Queueable has with strictly more control. Keep it in your vocabulary for reading legacy codebases, not writing new ones.
Queueable — the default choice
Queueable is the right answer for “I need to push this work out of the current transaction.” It accepts full object state through its constructor, returns an AsyncApexJob ID from System.enqueueJob, and supports chaining.
public class OrderSyncJob implements Queueable, Database.AllowsCallouts {
private List<Order> orders;
public OrderSyncJob(List<Order> orders) {
this.orders = orders;
}
public void execute(QueueableContext ctx) {
// Callouts allowed via Database.AllowsCallouts
// Full async limits: 60s CPU, 12MB heap
if (moreWorkRemains()) {
System.enqueueJob(new OrderSyncJob(remaining)); // exactly ONE child
}
}
}
The chaining rules trip people up in code review, so state them precisely:
- A transaction may call
System.enqueueJobup to 50 times - An executing Queueable may enqueue only one child job
- Chain depth is unlimited in production, capped at 5 in Developer and Trial orgs
AsyncOptions.MaximumQueueableStackDepthlets you cap depth deliberately, andQueueableDuplicateSignaturelets you block duplicate jobs from entering the queue
Transaction Finalizers — the retry mechanism Queueable was missing
A Finalizer runs after the Queueable completes, even when the job dies to an uncatchable limit exception. It is the only reliable place to implement retry:
public class SyncFinalizer implements Finalizer {
public void execute(FinalizerContext ctx) {
if (ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
// Log, alert, or re-enqueue with backoff.
// Max 5 consecutive re-enqueues from a finalizer.
}
}
}
Attach it with System.attachFinalizer(new SyncFinalizer()) inside execute(). If your Queueable performs callouts or DML that must not be silently lost, a Finalizer is not optional — it is the difference between a retried job and a support ticket three weeks later.
Batch Apex — for volume, not convenience
Batch exists for one reason: iterating over more records than a single transaction can hold. The start method returns a QueryLocator that can address 50 million records, and the platform feeds them to execute in scopes of up to 2,000 (default 200).
public class ContactRegionBatch implements Database.Batchable<SObject>, Database.Stateful {
public Integer updated = 0; // Stateful: survives across scopes
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Region__c FROM Contact WHERE Region__c = null'
);
}
public void execute(Database.BatchableContext bc, List<Contact> scope) {
// Fresh governor limits per scope
updated += scope.size();
}
public void finish(Database.BatchableContext bc) {
// Chain follow-up work here
System.enqueueJob(new NotifyJob(updated));
}
}
Batch-specific rules worth committing to memory:
- Only 5 batch jobs can be processing concurrently; up to 100 more wait in the flex queue. Job 106 fails to enqueue.
- Each
executescope gets fresh governor limits — this is the feature. A 2M-record update is 10,000 independent 200-record transactions. Database.Statefulpreserves instance state across scopes, at a serialization cost on every scope boundary. Use it for counters and ID sets, not for dragging large collections through the run.- Calling @future from
executethrows. Enqueue a Queueable instead.
The query powering start is where batch jobs over large data volumes live or die — an unselective QueryLocator against a 40M-row object can time out before the first scope runs.
Scheduled Apex — the trigger, not the worker
Scheduled Apex answers “when,” not “how much.” The execute method of a Schedulable runs under tight limits and is the wrong place for real work — its job is to start a Batch or enqueue a Queueable.
public class NightlySync implements Schedulable {
public void execute(SchedulableContext ctx) {
Database.executeBatch(new ContactRegionBatch(), 200);
}
}
// Seconds Minutes Hours DayOfMonth Month DayOfWeek
System.schedule('Nightly sync', '0 0 2 * * ?', new NightlySync());
Two operational constraints: an org holds at most 100 scheduled jobs, and a class that is referenced by an active scheduled job is locked against deployment unless you abort the job first — a regular source of failed Friday deployments.
The decision framework
Work through these in order; the first match wins.
- Is it time-driven? → Scheduled Apex, delegating immediately to Batch or Queueable.
- Does it iterate more records than one transaction can process (roughly >10,000, or any unbounded set)? → Batch Apex.
- Everything else — callouts after DML, heavy computation, decoupling from a trigger context → Queueable with a Finalizer.
- Never → @future, unless you are maintaining code that already uses it.
A corollary for trigger code: triggers should never decide how async work runs. Route the decision through a handler layer — the trigger frameworks guide covers where that responsibility belongs.
Async governor limits compared
| Limit | Synchronous | All async contexts |
|---|---|---|
| CPU time | 10,000 ms | 60,000 ms |
| Heap size | 6 MB | 12 MB |
| SOQL queries | 100 | 200 |
| SOQL rows retrieved | 50,000 | 50,000 |
| DML statements | 150 | 150 |
| Callouts | 100 | 100 |
The doubled CPU and heap are why “move it to async” genuinely fixes some limit exceptions — and why it merely postpones others. Rows, DML and callout ceilings do not move; if your synchronous code dies on 50,001 rows, Queueable dies on the same row.
Monitoring — query it, don’t click it
Setup → Apex Jobs shows the last 7 days, but production monitoring should be a query:
SELECT Id, JobType, Status, NumberOfErrors, ExtendedStatus,
MethodName, CompletedDate
FROM AsyncApexJob
WHERE CreatedDate = TODAY AND Status IN ('Failed', 'Aborted')
ORDER BY CreatedDate DESC
Wire that into a scheduled health check that posts failures to your alerting channel. The orgs that discover async failures from users are the orgs that never wrote this query.
The five mistakes that reach production
- Chaining Queueables without a depth guard — works in the happy path, then a data condition makes the chain self-perpetuating and consumes the daily async allowance. Pass a depth counter or set
MaximumQueueableStackDepth. - @future for callouts after DML — it works until someone needs the result, a retry, or ordering. Queueable from day one costs nothing extra.
- Batch scope of 2,000 with callouts — 100 callouts per transaction is the ceiling regardless of scope size; a 2,000-record scope needing one callout each fails on record 101. Size scope to the callout budget.
- Stateful batches carrying heavy collections — every scope boundary serializes the whole instance. Carry IDs and counters; re-query everything else.
- No Finalizer on business-critical Queueables — an uncatchable limit exception loses the work invisibly. Finalizers exist precisely for this.
Async Apex is not about escaping governor limits — it is about choosing which limits you operate under, and building the monitoring to know when you hit them.
Test your knowledge — Apex
10 questions · Basic to Advanced