Salesforce ships four ways to run Apex outside the current transaction: Future methods, Queueable, Batch Apex, and Scheduled Apex. Each has a specific shape, a specific governor limit profile, and a specific failure mode. Engineers new to Salesforce often pick whichever pattern they encountered first. The result is code that "works" in a developer org with 10 records and falls over in production with thousands.
The selection question is not academic. The wrong choice locks the implementation into a pattern that has to be refactored later, often under time pressure when the failure mode emerges.
Future methods: the legacy default
@future is the oldest async pattern in Apex. The method is annotated, accepts only primitive types, and runs asynchronously some time after the calling transaction commits.
public class AccountService {
@future
public static void syncToExternal(Set<Id> accountIds) {
// ... runs async ...
}
}
When to use Future today: almost never for new code. It was the only option before Queueable shipped in 2015, so a lot of legacy code uses it. New implementations should prefer Queueable.
Why avoid Future:
- Cannot accept sObject parameters. Only primitives, which forces ID lists and a re-query.
- Cannot be chained or sequenced.
- No way to query its execution status programmatically.
- Limited to 50 calls per transaction.
- No retry on failure.
The one case where Future still applies: synchronous callouts that must not block the current transaction, where the simpler pattern is acceptable. Even then, Queueable with the Database.AllowsCallouts interface is more flexible.
Queueable: the modern default
Queueable is the recommended async pattern for most new Apex work. The class implements Queueable and is enqueued with System.enqueueJob(...).
public class AccountSyncQueueable implements Queueable, Database.AllowsCallouts {
private Set<Id> accountIds;
public AccountSyncQueueable(Set<Id> ids) {
this.accountIds = ids;
}
public void execute(QueueableContext qc) {
// ... runs async ...
// Can chain: System.enqueueJob(new NextStepQueueable(...));
}
}
What Queueable does well:
- Accepts complex types (sObjects, custom classes) via constructor.
- Can chain itself or other Queueables for multi-step async workflows.
- Job ID returned by
enqueueJob is queryable via AsyncApexJob.
- Supports callouts (with
Database.AllowsCallouts).
- Higher async governor limits than synchronous context.
When to use Queueable:
- Any async work involving sObjects or complex types.
- Sequential async steps where step N depends on step N-1.
- Callouts that should not block the user's transaction.
- Most "fire and continue" patterns.
What Queueable does not do well:
- Volumes above 50,000 records in one job. Use Batch Apex instead.
- Scheduled execution at fixed times. Use Scheduled Apex.
Batch Apex: high-volume work
Batch Apex processes large data sets by chunking. The class implements Database.Batchable<sObject> with three methods: start, execute, and finish. The framework calls start once to define the data set, then calls execute repeatedly with chunks of 200 records (configurable up to 2,000).
public class CaseAgingBatch implements Database.Batchable<sObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Status FROM Case ' +
'WHERE CreatedDate < LAST_N_DAYS:90'
);
}
public void execute(Database.BatchableContext bc, List<Case> scope) {
// Process up to 200 cases per execution.
}
public void finish(Database.BatchableContext bc) {
// Optional cleanup, notifications, chained job kickoff.
}
}
// Run:
Database.executeBatch(new CaseAgingBatch(), 200);
When to use Batch Apex:
- Operations on more than 50,000 records.
- Data migration cleanup that must complete reliably.
- Nightly maintenance jobs (often combined with Scheduled).
- Anything that would breach synchronous heap or CPU limits.
What Batch Apex does well:
- Handles up to 50 million records per job.
- Each chunk runs in its own transaction with its own governor limits.
- Resumable: a failed chunk does not roll back the others.
- Higher async governor limits.
What Batch Apex does poorly:
- Latency. Jobs are queued and may not start immediately, especially when the org is busy.
- Per-record processing. The 200-record chunk size is a sweet spot, but operations that genuinely process records individually inside the chunk hit per-execution limits.
- Concurrent runs. Only 5 active or queued batch jobs allowed simultaneously (with some exceptions). High-frequency batch scheduling can hit this limit.
Scheduled Apex: time-driven execution
Scheduled Apex fires at defined times. The class implements Schedulable and is scheduled via the UI or System.schedule(...).
public class NightlyCleanupSchedule implements Schedulable {
public void execute(SchedulableContext sc) {
// Trigger Batch Apex from inside the Schedulable.
Database.executeBatch(new CaseAgingBatch(), 200);
}
}
// Schedule:
String cron = '0 0 2 * * ?'; // 2am every day
System.schedule('Nightly Case Aging', cron,
new NightlyCleanupSchedule());
When to use Scheduled Apex:
- Recurring jobs at fixed times (nightly, weekly, monthly).
- Time-based triggers that the platform's standard Time-Based Workflows cannot handle.
Scheduled Apex is rarely the workhorse itself. It is typically used to fire off a Batch or Queueable that does the actual work. Keep Schedulable.execute short.
What to watch:
- Cron strings are tricky to get right. Test the schedule with
System.schedule in a sandbox before relying on it in production.
- Scheduled jobs run as the user who scheduled them. Permission gotchas if that user lacks access to objects the job touches.
- Maximum 100 scheduled jobs total per org. Heavy use of
System.schedule in dynamic patterns can hit this limit.
A decision tree
For each new async requirement, run through:
- Is the work fixed-schedule (nightly, weekly)? → Scheduled Apex (kicking off Batch or Queueable).
- Is the data volume above 50,000 records? → Batch Apex.
- Does the work involve sObjects, callouts, or chained steps? → Queueable.
- Is this legacy code that already uses Future? → Leave it for now; refactor opportunistically.
- None of the above? → Queueable is the safe default.
Governor limit differences
Each async context has different limits. The ones that matter most:
| Context |
SOQL queries |
DML statements |
CPU time |
| Synchronous |
100 |
150 |
10,000ms |
| @future |
200 |
150 |
60,000ms |
| Queueable |
200 |
150 |
60,000ms |
| Batch Apex (per execute) |
200 |
150 |
60,000ms |
The async contexts roughly double the SOQL limit and 6x the CPU time. This is why moving expensive synchronous work to async often resolves governor limit issues even when nothing else changes.
Common async mistakes
Five patterns Sapota has seen in audits:
- Future overused. Legacy orgs are full of @future methods that should have been Queueable. The cost is operational; the code keeps working until something requires chaining or sObject parameters.
- Queueable used for huge volumes. A Queueable that tries to process 100,000 records in one execute. Hits async limits. Use Batch.
- Batch Apex for small volumes. A Batch job that processes 1,000 records. The framework overhead is wasted. Use Queueable.
- Scheduled Apex doing the work directly. A schedulable's execute method runs business logic synchronously. Hits limits at scale. Schedulable should kick off Batch or Queueable.
- Async without testability. Async methods without
Test.startTest/Test.stopTest boundaries in their tests. The async work never runs during the test, coverage is misleading.
What good async architecture looks like
A Salesforce org with healthy async patterns:
- New code defaults to Queueable for async work.
- @future code marked for opportunistic refactor when touched.
- Batch Apex used for operations clearly above 50K records.
- Scheduled Apex used for time-based kickoff, not for the work itself.
- Async methods tested with
Test.startTest/Test.stopTest to force execution.
- AsyncApexJob queried for observability and operational dashboards.
Sapota's Salesforce team holds the Platform Developer I credential and selects async patterns deliberately during architecture design, not after the first scale incident. The selection has long-lived consequences for code maintainability and operational behavior.
Designing async Apex patterns or refactoring legacy @future code? Sapota's Salesforce team handles async architecture, batch processing design, and scheduled job operations on production engagements. Get in touch ->
See our full platform services for the stack we cover.