Platform
Async Apex: Queueable vs Batch vs future vs Scheduled, and where each breaks
Four ways to run Apex off the main thread, and most teams pick by habit. The right choice is set by three questions: how many records, do you need to monitor or chain the job, and do you need to keep state. Here's the decision, the limits that bite, and the pattern that keeps an agent action from timing out.
You need to run some Apex off the main thread. Maybe a callout you can’t make after DML, maybe a job over more records than one transaction can hold, maybe work that has to run at 2am. Salesforce gives you four tools for this, and most teams reach for whichever one they learned first.
That habit is how you end up with a @future method capped at 50 calls per transaction, or a Batch job you can’t monitor, or a Queueable chain that dies in a sandbox for a reason that never shows up in production. The four are not interchangeable. Each one is the right answer to a specific question and the wrong answer to the others.
The decision comes down to three questions, and the rule falls out of them. First, how many records: a handful, or millions. Second, whether you need to track, chain, or recover the job after it runs. Third, whether you need to carry state across the work. Answer those three and the tool picks itself.
The one limit they all share
Before the differences, the ceiling they sit under. Every async Apex execution, Batch, Queueable, scheduled, and future alike, draws from one shared pool: 250,000 asynchronous executions per rolling 24 hours, or the number of user licenses times 200, whichever is greater. It sits alongside the rest of the governor limits, but this one is org-wide.
That number is large enough that most orgs never see it, until one does something like enqueue a Queueable per record in a trigger on a bulk load. Then a single data import burns six figures of async executions in a minute and every scheduled job in the org starts failing.
The pool being org-wide is the part that hurts: a runaway job in one corner starves everything else. Design so your async volume scales with batches of records, not with the record count itself.
With that ceiling in mind, here’s each tool and the question it answers.
Future: the legacy option, and why it’s rarely the answer
A future method is the oldest async tool: annotate a static method with @future, and Salesforce runs it later on its own thread.
public class GeocodeService {
@future(callout=true)
public static void geocode(Set<Id> addressIds) {
// primitives or collections of primitives only, never sObjects
}
}
The constraints are the story. A future method takes only primitives or collections of primitives, never sObjects, because the record could change between enqueue and execution. You get at most 50 future invocations per transaction. You cannot call a future from another future or from Batch Apex, and once it’s running you cannot monitor it, chain from it, or recover it, because it returns nothing you can track.
For a fire-and-forget callout from a trigger, it still works. But Queueable does everything future does and more, with a job ID you can watch and a finalizer you can recover from. I reach for future only in legacy code that already uses it. For anything new, the next tool is strictly better.
Queueable: the default for most async work
Queueable is what future should have been. You implement the interface, System.enqueueJob returns an AsyncApexJob ID you can query and monitor, and the job accepts sObjects and complex types as member variables.
public class AccountEnricher implements Queueable, Finalizer {
private List<Id> accountIds;
public AccountEnricher(List<Id> accountIds) { this.accountIds = accountIds; }
public void execute(QueueableContext ctx) {
System.attachFinalizer(this); // attach first, before the work
// enrich the accounts, make a callout, do the DML
}
// runs after the job finishes, success or failure, in its own transaction
public void execute(FinalizerContext ctx) {
if (ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
System.enqueueJob(new AccountEnricher(accountIds)); // one retry
}
}
}
Two limits shape how you use it. In a synchronous transaction you can enqueue up to 50 jobs, but from inside a running Queueable you can enqueue only one child. That single-child rule is deliberate: it stops a Queueable from fanning out into an uncontrolled swarm. Chaining is how you process more than one transaction’s worth of work, one job handing to the next.
Chain depth is effectively unlimited in production, but Developer and Trial orgs cap it at five levels, which is the classic “works in prod, dies in the sandbox” bug. If a chain fails at level five only in your scratch org, that’s the cap, not your code.
The finalizer is the piece most teams miss. A transaction finalizer attaches an action that runs after the job completes, whether it succeeded or threw, in a fresh transaction with its own limits. That’s how you do reliable error handling and retry in async Apex: log the failure, notify, or re-enqueue.
Attach it at the very start of execute, because if the exception fires before attachFinalizer runs, the finalizer never does. A failed job can be re-enqueued by its finalizer up to five times before the platform stops the chain.
Batch Apex: when the data won’t fit in one transaction
Queueable handles a lot, but it runs inside one set of governor limits. When you need to process hundreds of thousands or millions of records, one transaction can’t hold them, and that’s the job Batch Apex exists for.
public class ContactSyncBatch implements Database.Batchable<SObject>, Database.Stateful {
public Integer failures = 0; // survives across chunks because of Stateful
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Email FROM Contact WHERE Sync_Pending__c = true');
}
public void execute(Database.BatchableContext bc, List<Contact> scope) {
// process this chunk; governor limits reset for every chunk
}
public void finish(Database.BatchableContext bc) {
// summary email, or chain the next job
}
}
The mechanic that makes Batch different: the start method’s QueryLocator can return up to 50 million records, and the platform hands them to execute in chunks. The default chunk size is 200 records, configurable up to 2,000 when you call Database.executeBatch(new ContactSyncBatch(), 200). Governor limits reset for each chunk, so a million-record job never hits a single-transaction limit.
The limits that bite are about concurrency and state. Only five batch jobs run or queue at once; beyond that, jobs sit in the Apex flex queue in Holding status, which caps at 100.
And each execute chunk is its own transaction, so instance variables reset between chunks unless the class implements Database.Stateful. A running total that resets to zero every 200 records is the most common Batch bug, and the fix is one interface.
Batch is heavier to run and slower to start than Queueable, so don’t reach for it out of habit. Use it when the volume genuinely exceeds what a Queueable chain can chew through, or when you want the chunked, restartable structure for a long data operation. For large volumes that don’t need the full Batch lifecycle, Apex Cursors are the newer, lighter option worth checking first.
Scheduled Apex: the clock, not the workload
Scheduled Apex answers a different question, about time rather than volume. You implement Schedulable and register it with a cron expression, and the platform runs it on that schedule.
public class NightlyRollup implements Schedulable {
public void execute(SchedulableContext ctx) {
Database.executeBatch(new ContactSyncBatch(), 200);
}
}
// System.schedule('Nightly Rollup', '0 0 2 * * ?', new NightlyRollup());
Scheduled Apex almost never does the work itself. It’s a trigger for other async jobs, most often kicking off a Batch at 2am.
The main limit is that you can have 100 scheduled Apex jobs in an org at once, so don’t schedule one job per account; schedule one job that processes all of them. A scheduled job also holds a lock that can block deployments touching its class, so plan releases around it.
The pattern that keeps an agent action from timing out
Async Apex does more than chew through data volume. It’s also how you keep a synchronous caller from timing out, and that matters more now that agent actions call Apex.
An Agentforce Apex action runs synchronously inside the conversation, under governor limits and a callout budget, the same way an External Services action does. If the real work is slow, a batch export, a long callout chain, a report that takes minutes, doing it inline blows the budget and the agent stalls mid-answer.
The pattern is the same one you’d use for any slow synchronous caller. The action starts the async job and returns a handle immediately: “I’ve kicked that off, I’ll update you when it’s done.” A Queueable does the work, and its finalizer or a platform event signals completion back into the conversation or the record.
The agent stays responsive because the expensive part left the request thread. This is the async-first thinking the governor-limit collision forces on any agent that does real work.
Making the call
Answer the three questions. If the work is a handful of records and you want monitoring, chaining, or recovery, use Queueable, which is the right default for most async work. If it’s a legacy fire-and-forget callout already written with future, leave it; don’t write new future code.
If the volume is hundreds of thousands to millions of records, use Batch, and implement Database.Stateful the moment you need a running total. If the trigger is a clock rather than a workload, use Scheduled to kick off a Batch or Queueable.
And whichever you choose, respect the shared 24-hour pool, attach a finalizer when a Queueable failure needs handling, and move slow work off any synchronous caller, an agent action most of all, before it times out on you.
Understanding the basics
What is the difference between Queueable and future in Apex?
Both run Apex asynchronously, but Queueable is strictly more capable. It returns a job ID you can monitor, accepts sObjects and complex types instead of only primitives, supports chaining one child job, and supports transaction finalizers for error handling and retry. Future methods offer none of that and cap at 50 calls per transaction. Use Queueable for any new async work; keep future only in legacy code that already relies on it.
When should I use Batch Apex instead of Queueable?
Use Batch Apex when the data volume exceeds what one transaction can hold, roughly when you’re processing hundreds of thousands to tens of millions of records. Batch’s start method can return up to 50 million records and processes them in chunks of up to 2,000, with governor limits resetting per chunk. For smaller jobs, Queueable is lighter and faster to start. For very large reads that don’t need the full Batch lifecycle, Apex Cursors are a newer alternative to weigh first.
What is the async Apex governor limit?
All asynchronous Apex, Batch, Queueable, scheduled, and future, shares one pool: 250,000 executions per rolling 24 hours, or the number of user licenses times 200, whichever is greater. It’s org-wide, so a runaway job that enqueues async work per record can exhaust it and cause unrelated scheduled and batch jobs to fail. Design async volume to scale with batches of records rather than with the raw record count.
How do I handle errors and retries in Queueable Apex?
Use a transaction finalizer. A class implementing the Finalizer interface, attached with System.attachFinalizer at the start of the Queueable’s execute method, runs after the job finishes whether it succeeded or threw, in its own transaction. From there you can log the failure, send a notification, or re-enqueue the job. A failed Queueable can be re-enqueued by its finalizer up to five times before the platform stops the chain to prevent an infinite loop.
Untangling which async pattern a job needs, or debugging a Queueable chain that works in production and dies in a sandbox, or keeping an agent action responsive while the real work runs behind it? Talk to us. Getting Apex to scale without tripping a governor limit is exactly the work we do.
Keep reading
All insights