Why Async at All
Synchronous Apex has tight governor limits — 100 SOQL queries, 150 DML, 10 seconds CPU. For operations that exceed any of these, you need to defer work to an async context where the limits are higher and the timing is decoupled from the user’s click.
Salesforce offers four async options. Each has a purpose. Picking the right one is the difference between a robust integration and a fragile one.
@future Methods
The oldest and simplest async option. Mark a static method with @future and it runs in a separate transaction.
@future(callout=true)
public static void syncToExternalSystem(Set<Id> accountIds) {
// HTTP callouts allowed here
}
Use when:
- You need to make callouts from a trigger (callouts from synchronous triggers are forbidden).
- You have simple fire-and-forget work.
- You don’t need to chain subsequent async work.
Don’t use when:
- You need to chain async jobs — @future cannot call another @future.
- You need to pass complex objects — @future parameters must be primitive types or collections of primitives (ids, strings, numbers).
- You need to retry on failure — @future has no native retry.
@future is the legacy option. For new code, prefer Queueable.
Queueable Apex
Implement the Queueable interface. Enqueue with System.enqueueJob(). Runs in a separate transaction with async limits.
public class SyncAccountsQueueable implements Queueable, Database.AllowsCallouts {
private Set<Id> accountIds;
public SyncAccountsQueueable(Set<Id> ids) { this.accountIds = ids; }
public void execute(QueueableContext ctx) {
// work, including callouts
// optionally chain: System.enqueueJob(new NextStepQueueable(...));
}
}
Use when:
- You want a richer async primitive than @future.
- You need to chain jobs (one Queueable enqueuing another).
- You want to pass complex objects (Queueable supports sObjects and custom types).
- You want to retry — catch exceptions, re-enqueue with backoff logic.
Don’t use when:
- You need to process millions of records — use Batch.
- You need to run on a schedule — use Scheduled.
Queueable is the default modern async choice for most integration work.
Batch Apex
Implement the Database.Batchable interface. Called via Database.executeBatch(). Processes records in chunks; each chunk gets fresh governor limits.
public class RecalculateAccountHealth
implements Database.Batchable<SObject>, Database.Stateful {
public Database.QueryLocator start(Database.BatchableContext ctx) {
return Database.getQueryLocator('SELECT Id, AnnualRevenue FROM Account');
}
public void execute(Database.BatchableContext ctx, List<Account> scope) {
// process up to 200 records (configurable up to 2000)
}
public void finish(Database.BatchableContext ctx) {
// cleanup or email
}
}
Use when:
- You need to process more than a few thousand records.
- You need to aggregate across very large datasets.
- You’re running an overnight data cleanup or recalculation.
Don’t use when:
- Record counts are small (a Queueable is simpler).
- You need low-latency processing — batch jobs queue and may not start immediately.
Batch Apex supports scope sizes up to 2,000 records per execute call. Lower scope sizes make DML-heavy work more stable. Tune based on what the execute method does.
Scheduled Apex
Implement the Schedulable interface. Schedule via System.schedule() with a cron expression. Runs at defined times.
public class NightlyOpportunitySync implements Schedulable {
public void execute(SchedulableContext ctx) {
// kick off the real work — often a Batch job
Database.executeBatch(new SyncOpportunities(), 200);
}
}
Use when:
- You have recurring time-based work (nightly, hourly, every Monday).
- You need to kick off a Batch or Queueable at a specific time.
Don’t use when:
- The event is data-triggered, not time-triggered.
- You need sub-minute scheduling — Scheduled Apex supports minute-level precision but is not a real-time primitive.
A common pattern: Scheduled Apex simply enqueues a Batch or Queueable. The real work lives in the batch class; the schedulable is a lightweight scheduler.
Decision Tree
Is the work time-triggered?
YES → Scheduled (likely invoking Batch or Queueable)
NO ↓
Is the data volume > 10,000 records?
YES → Batch Apex
NO ↓
Do you need callouts from a trigger context?
YES → Queueable (or @future for very simple cases)
NO ↓
Do you need to chain async jobs?
YES → Queueable
NO ↓
Default: Queueable.
Governor Limits in Async Context
Async Apex gets higher limits than synchronous:
| Limit | Sync | Async |
|---|---|---|
| SOQL queries | 100 | 200 |
| DML statements | 150 | 200 |
| Total records queried | 50,000 | 50,000 |
| Total DML records | 10,000 | 10,000 |
| CPU time | 10 s | 60 s |
| Heap size | 6 MB | 12 MB |
Batch Apex gets these limits per execute chunk, which is why batch is the path for very large processing — you effectively get multiplied limits.
Queuing and Throttle
There are platform-wide limits on how many async jobs can run. Don’t enqueue thousands at once without a plan.
- Max 100 queued/active Apex jobs at any time across Queueable, Batch, and Scheduled.
- Max 5 concurrent Batch jobs.
- Daily limit on async executions based on license count.
A pattern that enqueues one Queueable per record for 10,000 records will blow through queue limits. Instead, enqueue one Queueable that processes 100–500 records, which enqueues the next Queueable when done.
Error Handling
Each async option has its own error surface. Design for failure:
Queueable: wrap execute in try/catch. On failure, log to a custom Async_Error__c object and optionally re-enqueue with a retry counter.
Batch: finish method is the place to summarize results and email errors. Use Database.Stateful to carry error counts across executions.
Scheduled: the execute itself is usually a dispatcher; real error handling lives downstream.
@future: limited — failures produce a debug log entry and a rolled-back transaction. Monitor via setup.
Testing Async
All four async types have testing idioms:
Test.startTest();
// enqueue / invoke async work
Test.stopTest();
// assertions — the async work has completed by this point
Test.stopTest() forces async work to complete synchronously within the test. Without this pattern, async tests simply don’t observe the async results.
Frequently Asked Questions
Can I call a Queueable from a Queueable?
Yes, and this is the primary chaining pattern. Depth is limited (around 50 chained jobs) but sufficient for typical use.
Can Batch call Queueable?
Yes, from finish. Don’t enqueue from execute — you’ll burn through queueable limits fast.
Is Scheduled Apex reliable for critical schedules?
It’s reliable enough for most use cases, but scheduled jobs can be delayed under heavy platform load. For critical SLAs, monitor start times.
Can I cancel a Queueable?
Yes — System.abortJob(jobId) cancels a queued job. For running jobs, the job completes; only pending jobs are cancellable.