← Back to Field Notes

Async Apex Patterns: Queueable vs Batch vs Future — The Decision Tree

·9 min read

The Salesforce platform offers four ways to run Apex code asynchronously: Future methods, Queueable Apex, Batch Apex, and Scheduled Apex. Most developers reach for Future first because it's the oldest and the syntax is simplest. Most production headaches in async Apex come from that decision. Future is rarely the right answer in 2026.

I've debugged enough async chains across enterprise orgs to have a strong opinion about which to use when. The choice isn't aesthetic — different patterns have different governor limits, different chaining behaviour, and different debug visibility. Pick the wrong one and the code works in dev and fails at scale. Here's the decision framework I actually apply.

The Short Answer

For 80% of cases, the right answer is Queueable. It's the modern default. It supports passing complex types, it can chain to other Queueables, and it's the easiest to test. Default to Queueable unless you have a specific reason not to.

Use Batch Apex when you need to process more than 50,000 records — that's the only case where Batch's chunking is actually required. For anything smaller, Queueable is simpler.

Use Future only for legacy compatibility or when you absolutely need the lightweight fire-and-forget model — and even then, consider whether Queueable would do.

Use Scheduled Apex when you genuinely need a cron-style schedule. Don't use it as a "delay" mechanism for one-off async work; that's what Queueable's enqueueJob with a delay parameter is for.

The rest of this article is the long version of why.

Future: The Pattern I Avoid

@future(callout=true)
public static void doSomethingAsync(Id recordId) {
    // Process the record
}

Future methods are simple, which is why they show up in old codebases. The pattern's limitations become obvious when you scale:

  • Cannot pass SObjects or complex types. Only primitives and collections of primitives. You pass record IDs and re-query them inside the method, adding SOQL overhead.
  • Cannot chain. A Future method cannot call another Future method. If you need a multi-step async chain, you have to redesign.
  • Cannot be monitored well. No Id is returned when you invoke a Future. You can't track its status, retry it on failure, or know when it completed without writing your own logging.
  • Limited to 50 calls per Apex transaction. Easy to hit in bulk operations.

The one thing Future does that Queueable doesn't: it can be invoked from a Visualforce controller without Test.startTest(). This is a niche advantage. Almost every modern use case has migrated past it.

Queueable: The Modern Default

public class AccountProcessor implements Queueable, Database.AllowsCallouts {

    private List<Id> accountIds;

    public AccountProcessor(List<Id> accountIds) {
        this.accountIds = accountIds;
    }

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

        // Optionally chain
        if (moreWork) {
            System.enqueueJob(new AccountProcessorPart2(accountIds));
        }
    }
}

// Caller
Id jobId = System.enqueueJob(new AccountProcessor(accountIds));

Queueable's advantages over Future:

  • Pass any type. Constructor takes complex objects, lists of SObjects, custom types. No need to re-query.
  • Chain freely. Each Queueable can enqueue another Queueable. Build async pipelines naturally.
  • Returns a job Id. Track status via AsyncApexJob. Retry mechanisms become possible.
  • Higher transaction limit. 50 stack-depth chained Queueables per transaction in production (1 in tests, which is the gotcha for testing).
  • Better debugging. Job appears in Apex Jobs setup UI; failure stack traces are accessible.

The limitations:

  • One Queueable per transaction. You can enqueueJob exactly once from a synchronous transaction. After that, only the executing Queueable itself can enqueue more.
  • Test limitations. Inside Test.startTest()/Test.stopTest(), only one Queueable executes — chains don't run automatically. You have to test each link separately.
  • Transactional context. A chained Queueable runs in a separate transaction from its parent. This means uncommitted changes from the parent aren't visible to the child unless you commit them first.

The one-Queueable-per-transaction rule is the trap people hit. The fix: if you need multiple async tasks from a synchronous trigger, batch them into a single Queueable that processes all of them, or use a Platform Event to trigger multiple downstream consumers.

Batch Apex: For Volume, Not Convenience

public class AccountAuditBatch implements Database.Batchable<SObject>, Database.Stateful {

    public Database.QueryLocator start(Database.BatchableContext context) {
        return Database.getQueryLocator('SELECT Id, Name, Type FROM Account WHERE LastModifiedDate < LAST_N_MONTHS:6');
    }

    public void execute(Database.BatchableContext context, List<Account> scope) {
        // Process up to 200 accounts per chunk
        for (Account a : scope) {
            // ...
        }
        update scope;
    }

    public void finish(Database.BatchableContext context) {
        // Send completion notification, kick off next batch, etc.
    }
}

// Caller
Id jobId = Database.executeBatch(new AccountAuditBatch(), 200);

Batch Apex's superpower is volume. It can process queries that return millions of rows by chunking automatically. Each chunk runs in its own transaction with its own governor limits.

Use Batch when:

  • You need to process more than 50,000 records (the SOQL row limit). Batch's chunking is the only way.
  • You're running a long-lived job that benefits from per-chunk error isolation (one chunk fails, others continue).
  • You need state to persist across chunks (Database.Stateful).

Don't use Batch when:

  • The dataset is under 50,000 records. Queueable handles this with less ceremony.
  • You need synchronous-feeling behaviour. Batch jobs are queued and may not run for several minutes.
  • You need fine-grained progress reporting. Batch reports per-chunk, not per-record.

The most common Batch antipattern: using it for any async work because "batch" sounds like it handles bulk well. For under 50k records, Queueable processes them in a single transaction with the same governor limits Batch gets per chunk — and runs sooner.

Scheduled Apex: For Genuine Schedules

public class NightlyAccountCleanup implements Schedulable {
    public void execute(SchedulableContext context) {
        // Kick off the actual work — usually a Queueable or Batch
        Database.executeBatch(new AccountAuditBatch(), 200);
    }
}

// Schedule it (one-time setup):
String cron = '0 0 2 * * ?'; // Every day at 2am
System.schedule('Nightly Account Cleanup', cron, new NightlyAccountCleanup());

Scheduled Apex is the only async pattern with a built-in cron schedule. Use it for genuine recurring work — nightly cleanup, weekly reports, monthly aggregations.

Don't use it as a "delay" mechanism. If you want to run something in 5 minutes, the right tool is Queueable's delay parameter (System.enqueueJob(new MyJob(), 5) enqueues with a 5-minute delay, supported in modern API versions).

The pattern: Schedulable kicks off Batch or Queueable. Don't put real work inside execute(SchedulableContext). Schedulable's job is timing; Batch and Queueable do the actual processing.

The Decision Tree

When I'm building an async path, I walk through this:

  1. Does it need a cron schedule? Yes → Schedulable that kicks off Queueable or Batch.
  2. Does it process more than 50,000 records? Yes → Batch.
  3. Does it process a meaningful number of records (1k–50k) and benefit from chunking-per-error? Yes → Batch.
  4. Does it need to chain async steps? Yes → Queueable (with chained Queueables).
  5. Does it process under 1,000 records and run as a single async job? Yes → Queueable.
  6. Are you migrating from old Future-based code? → Migrate to Queueable. The work is small.
  7. Edge cases that genuinely need Future → Document why. There aren't many.

The flowchart resolves to Queueable in most cases. That's the point.

The Patterns I See Misused

A few specific antipatterns I've debugged in production:

Future methods called in a loop. Each @future invocation consumes a Future job slot, capped at 50 per transaction. A trigger that fires @future for each record breaks at 50 records bulk-inserted. The fix is bulkify into one Future that takes a list of IDs.

Queueable chains that violate the depth limit. In production, Queueable can chain up to 50 deep. In tests, only the first Queueable runs synchronously. Code that "works in dev" because tests don't exercise the chain breaks the moment it ships and gets exercised at depth.

Batch jobs with chunk size 200 used for everything. The default chunk size is 200 records per execute(). For lighter work, increase it (up to 2000) so the job runs faster. For DML-heavy work or work that hits governor limits, reduce it. Picking the right chunk size is a tuning exercise; the default isn't always optimal.

Scheduled Apex doing the actual work. Schedulable runs in its own context with limited time and governor limits. Putting real processing inside it constrains the work unnecessarily. Always delegate to Queueable or Batch.

Mixing async patterns within a single feature. A feature that uses Queueable for one step, Batch for the next, and Future for a third is hard to reason about, hard to test, and hard to debug. Pick one async pattern per feature unless you have a specific reason to mix.

Testing Async Apex

The platform's test framework runs async code synchronously inside Test.startTest()/Test.stopTest(). This is a feature, not a bug — without it, async code would be untestable.

The catch: only the first level of async runs. A Queueable chain stops at the first link. A Batch with a finish method that enqueues another Batch — only the first runs. To test deeper chains, you have to mock the chaining or test each link separately.

For governor limit testing, assert on Limits.getQueries() and Limits.getDmlStatements() after Test.stopTest(). The async work counts against those limits, so you can verify the async path stays within them.

When the Decision Doesn't Matter

For low-volume async work in an internal-only org, the choice between patterns matters less than getting the work done. Don't spend a week deciding between Queueable and Batch when the dataset is 200 records and the difference is academic.

Where the decision matters:

  • High-volume orgs (millions of records)
  • Customer-facing features where slow async paths are visible
  • Integration callouts where retry behaviour and chaining matter
  • AppExchange packages where governor consumption affects subscriber orgs

For the kind of reviews I do, async Apex patterns are usually one of the top three findings. The fixes are mechanical once the right pattern is identified — but identifying the wrong pattern takes longer than fixing it.

The shortest summary: default to Queueable. Reach for Batch when the dataset is genuinely large. Use Schedulable only for cron-style timing. Avoid Future. The async system rewards the boring choice.

Liked this?

New essays in your inbox when I publish. No cadence, no spam.