apex.
apex10 min read

apex queueable vs batch vs future when to use

Decision matrix for async Apex — Queueable for chained jobs with state, Batch for >10k-record sweeps, Future for fire-and-forget callouts; covers the 50 jobs/transaction and 250k async-executions/day limits.

Apex Queueable vs Batch vs Future: A Practical Decision Matrix

A couple of years back, I inherited a Salesforce org where every async job was a @future method. Monday mornings, when marketing kicked off their weekly import, the org silently dropped about 3% of contacts — the @future quota was capping out and the trigger was swallowing the exception. The fix took two weeks to diagnose and about ten minutes to ship: rewrite six of those methods as Queueables and one as a Batch. That experience is why I now treat the choice between Queueable, Batchable, and @future as one of the more consequential design decisions in async Apex. The three look interchangeable in tutorials. They aren't — and this article is the decision matrix I use when reviewing them.

The three options at a glance

Most async Apex tutorials present these three as a progression: @future is old, Queueable is better, Batch is best. That framing is wrong — each one is the right answer for a specific shape of problem, and this section sketches the three shapes.

@future methods are the oldest async mechanism. You decorate a static void method, the method runs in a separate transaction, and you forget about it. No job ID, no monitoring hook, no chaining. Future methods accept only primitive arguments or collections of primitives. They're a hammer for one job: "do this thing later, I don't care when."

Queueable Apex arrived to fix the limitations of @future. You implement the Queueable interface, enqueue the job, and Salesforce hands you back a job ID you can monitor in AsyncApexJob. Queueables accept complex types (sObjects, custom classes), can be chained from inside their own execute method, and support a Finalizer callback for cleanup logic. Queueable is the modern default for anything that doesn't fit the other two patterns.

Batch Apex is the heavy machinery. You implement Database.Batchable<sObject>, return a QueryLocator from start, and Salesforce splits your query results into chunks of up to 2000 records that each run as their own transaction. Batch was built for sweeps over hundreds of thousands of records where governor limits would obliterate a single Queueable. The cost is complexity: three methods to implement, scheduled chunking, and per-chunk transaction overhead.

Decision matrix

Which tool do you reach for first? This table is the filter I run in my head, sorted by how often each pattern is the right answer in production code.

ScenarioUseWhy
Chained background jobs needing stateQueueablePass complex objects between jobs, get job IDs for monitoring
Sweep over 10,000+ recordsBatchablePer-chunk transactions reset governor limits
Single async callout, no follow-up@future(callout=true)Lightest setup, no monitoring overhead
Trigger-fired async work, primitives only@futureFastest to write, fewer moving parts
Periodic job (nightly cleanup, weekly export)Schedulable + BatchableScheduled batch handles both timing and volume
Chained callouts with retriesQueueable + FinalizerFinalizer fires even on unhandled exception
Real-time-ish job that must finish soonQueueableHigher priority queue than Batch in practice

Three rules sit behind every row:

  1. If your job touches more than 10,000 records in a single transaction, you cannot use Queueable or @future. The 10,000-row DML governor limit will throw System.LimitException before you finish. Batch is the only option, because each chunk resets the limit.
  2. If your job needs to pass an Account or a List<MyWrapper> to the next step, @future is out. Future methods cannot serialize sObjects or custom classes; you'd have to pass record IDs and re-query, which doubles your SOQL cost.
  3. If you need to know whether the job succeeded, @future is out. There's no AsyncApexJob row tied to a future invocation that's reliable to monitor. Queueable and Batch both give you job IDs and status fields.

The limit landscape

A colleague of mine once spent two days hunting a flaky integration test before realizing the nightly sync was silently capping out at the org's daily async ceiling — one of the three governor limits that drive almost every async Apex design conversation. Internalize these or you'll spend a sprint debugging cryptic exceptions.

50 async jobs per transaction: A single Apex transaction can start at most 50 @future, Queueable, or Batch jobs combined. This is the limit that catches teams who put System.enqueueJob inside a for loop over a trigger context. If 200 records trip the trigger, you get a LimitException on record 51. The fix is either to bulkify the Queueable (one job that processes all 200 records) or to use Batch from the start.

250,000 async method executions per 24 hours: This is the daily org-wide ceiling. The actual cap is max(250000, number-of-user-licenses * 200), so a 50-seat org has 250k; a 2000-seat org has 400k. Queueable enqueues, future invocations, and batch executions all count. A bulk data load that enqueues a Queueable per record will exhaust this quota fast.

5 active or scheduled Batch jobs at a time: Batch has its own ceiling. Five Database.executeBatch calls can be running or queued; the sixth throws AsyncException. Background data sweeps on a tight schedule can collide here.

Salesforce's governor limits reference lists the full set, but these three are the ones that change architecture decisions.

Queueable: the modern default

Queueable Apex covers about 70% of the async work I see in real codebases. The interface is small.

public class AccountEnrichmentQueueable implements Queueable, Database.AllowsCallouts {
    private List<Id> accountIds;
    private Integer chainDepth;

    public AccountEnrichmentQueueable(List<Id> accountIds, Integer chainDepth) {
        this.accountIds = accountIds;
        this.chainDepth = chainDepth;
    }

    public void execute(QueueableContext context) {
        List<Account> accounts = [
            SELECT Id, Name, Industry FROM Account WHERE Id IN :accountIds
        ];

        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Enrichment_API/lookup');
        req.setMethod('POST');
        req.setBody(JSON.serialize(accounts));
        HttpResponse res = new Http().send(req);

        if (res.getStatusCode() == 200) {
            applyEnrichment(accounts, res.getBody());
            update accounts;
        }

        if (chainDepth < 5 && needsFollowUp(accounts)) {
            System.enqueueJob(new AccountFollowUpQueueable(accountIds, chainDepth + 1));
        }
    }

    private Boolean needsFollowUp(List<Account> accts) {
        for (Account a : accts) {
            if (String.isBlank(a.Industry)) return true;
        }
        return false;
    }

    private void applyEnrichment(List<Account> accts, String body) {
        Map<String, Object> payload = (Map<String, Object>) JSON.deserializeUntyped(body);
        // map fields back onto accts
    }
}

Three patterns are worth pulling out of that example.

First, the constructor takes a List<Id> and a chainDepth counter. Passing IDs keeps the serialized job small. Passing the depth lets you cap the chain. A runaway Queueable chain can burn through your 250k daily quota without warning, and chain-depth limits are the cheapest defense.

Second, the class implements Database.AllowsCallouts because async callouts require the explicit marker. Forget this and your Http().send() throws at runtime, not compile time. This is one of those Salesforce gotchas that always slips past code review because it looks fine.

Third, chaining happens at the bottom of execute. Calling System.enqueueJob from inside a running Queueable counts against the 50-job-per-transaction limit, but the limit is enforced per transaction, and each Queueable execution is its own transaction. So you can chain indefinitely (well, up to your daily quota) as long as each link only enqueues one or two follow-ups.

For monitoring, query AsyncApexJob with the job ID returned by System.enqueueJob:

Id jobId = System.enqueueJob(new AccountEnrichmentQueueable(ids, 0));
AsyncApexJob job = [
    SELECT Id, Status, NumberOfErrors, JobItemsProcessed
    FROM AsyncApexJob
    WHERE Id = :jobId
];

For unhandled exceptions, attach a Finalizer via System.attachFinalizer(myFinalizer) inside execute. The finalizer runs even if the main method throws, which is the only reliable hook for retry logic on async failures.

Batch: the heavy machinery

Batch Apex is the answer when "we need to update 800,000 contacts overnight" lands in your inbox. The interface forces you to think in chunks.

public class ContactSegmentationBatch
        implements Database.Batchable<sObject>, Database.Stateful {

    public Integer totalProcessed = 0;

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator(
            'SELECT Id, MailingCountry, Account.AnnualRevenue FROM Contact ' +
            'WHERE LastModifiedDate = LAST_N_DAYS:7'
        );
    }

    public void execute(Database.BatchableContext bc, List<Contact> scope) {
        for (Contact c : scope) {
            c.Segment__c = computeSegment(c);
        }
        update scope;
        totalProcessed += scope.size();
    }

    public void finish(Database.BatchableContext bc) {
        Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
        email.setToAddresses(new String[] { 'ops@example.com' });
        email.setSubject('Segmentation batch finished');
        email.setPlainTextBody('Processed: ' + totalProcessed);
        Messaging.sendEmail(new Messaging.SingleEmailMessage[] { email });
    }

    private String computeSegment(Contact c) {
        Decimal rev = c.Account != null ? c.Account.AnnualRevenue : 0;
        if (rev > 10000000) return 'ENT';
        if (rev > 1000000) return 'MID';
        return 'SMB';
    }
}

// Kick it off:
Database.executeBatch(new ContactSegmentationBatch(), 200);

The Database.Stateful marker is what lets the totalProcessed counter survive between chunks. Without it, each execute call starts with a fresh class instance, and your aggregate counters reset to zero. This trips up everyone exactly once.

The chunk size, the second argument to executeBatch, is the lever that decides your governor limit ceiling. A chunk of 200 means 200-row DML per execution, well under the 10k row limit. Drop it to 50 if each row triggers heavy SOQL or callouts. Raise it to 2000 (the maximum) for cheap row updates. The right number is usually found by running once in a sandbox and watching CPU time in the debug log.

Why @future still exists

@future looks deprecated next to Queueable, but it still wins on three cases.

First, single async callout from a trigger context with primitive payload. The setup cost (one annotation) beats Queueable's class scaffolding when the job is genuinely fire-and-forget. Second, mixed-DML scenarios. When you need to update a setup object (User, Group, QueueSObject) immediately after updating a regular object in the same transaction, Salesforce throws MixedDmlOperationException. The cleanest workaround is a @future method that runs the setup-object DML in its own transaction. Third, existing code paths. A codebase with 200 @future methods doesn't need a rewrite. Refactor opportunistically when you touch a method, not as a sweep.

The signature constraints are strict.

@future(callout=true)
public static void notifyExternalSystem(Set<Id> recordIds, String reason) {
    // primitives and collections of primitives only
    // no sObjects, no custom classes
    for (Id recordId : recordIds) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Notify_API/' + recordId);
        req.setMethod('POST');
        req.setBody('{"reason":"' + reason + '"}');
        new Http().send(req);
    }
}

If you find yourself wrapping a record into a primitive bag to fit the signature, switch to Queueable. The translation back-and-forth costs more code than the interface would have.

Combining patterns

Real systems mix the three. A common shape looks like this:

  1. Trigger fires, bulkifies records into a Set<Id>, enqueues a single Queueable.
  2. Queueable does the first round of work, then enqueues a Batch if the record count exceeds 10,000.
  3. Batch processes in 200-record chunks, calls a @future(callout=true) per chunk for an external sync.
  4. Finalizer on the Queueable handles retries on unhandled exceptions.

The trick is keeping the boundaries explicit. Each pattern owns one phase of the workflow. A common anti-pattern is starting in @future, calling System.enqueueJob from inside it (which works), then enqueuing a Batch from the Queueable (which also works), and ending up with a four-layer chain where no single human knows what runs when. Lay out the workflow in a comment block at the top of the trigger handler before you write any of the async classes.

For a Queueable that needs to retry on failure, the Finalizer pattern is roughly 50ms of extra setup per job and gives you the only reliable async retry hook in the platform. That tradeoff almost always wins.

References