Advertisement

Apex

Apex Async — Queueable vs Batch vs @future vs Scheduled (Summer '26 Guide)

11 June 2026 · 13 min read · Intermediate

Choosing between Queueable, Batch, @future and Scheduled Apex is one of the most common asynchronous Apex decisions developers face — and choosing wrong is one of the most common reasons production orgs hit AsyncException storms, stuck flex queues and silent data loss. This guide gives you the decision framework, the governor limit differences, and the failure modes of each async primitive, current to Summer ‘26 and API v67.0.

If you have not internalized the synchronous limits first, read the Apex Governor Limits guide — async limits only make sense relative to their synchronous counterparts.

The four async primitives at a glance

PrimitiveBuilt forJob ID returnedChainingComplex params
@futureLegacy fire-and-forgetNoNoNo (primitives only)
QueueableSingle async unit of workYesYes (1 child per job)Yes (objects, sObjects)
BatchLarge record volumes in chunksYesVia finish()Via constructor
ScheduledTime-based executionYesStarts other asyncVia constructor

The platform also queues every one of these through the same Apex flex queue and enforces a shared ceiling: 250,000 async executions per 24 hours (or 200 × user licenses, whichever is greater). Every primitive draws from that pool.

@future — why it’s legacy

@future methods accept only primitive parameters, return nothing, cannot be chained, and give you no job ID to monitor. When a @future call fails, you find out from an email — if at all.

public class AccountEnricher {
    @future(callout=true)
    public static void enrich(Set<Id> accountIds) {
        // No job ID. No chaining. No retry hook.
        // If this throws, the work is silently lost.
    }
}

The two rules that still matter for @future in 2026:

  • You cannot call a @future method from another @future method or from Batch Apex — both throw at runtime.
  • Up to 50 @future invocations are allowed per transaction, and they are not guaranteed to execute in order.

There is no new code that should use @future. Every capability it has, Queueable has with strictly more control. Keep it in your vocabulary for reading legacy codebases, not writing new ones.

Queueable — the default choice

Queueable is the right answer for “I need to push this work out of the current transaction.” It accepts full object state through its constructor, returns an AsyncApexJob ID from System.enqueueJob, and supports chaining.

public class OrderSyncJob implements Queueable, Database.AllowsCallouts {
    private List<Order> orders;

    public OrderSyncJob(List<Order> orders) {
        this.orders = orders;
    }

    public void execute(QueueableContext ctx) {
        // Callouts allowed via Database.AllowsCallouts
        // Full async limits: 60s CPU, 12MB heap

        if (moreWorkRemains()) {
            System.enqueueJob(new OrderSyncJob(remaining)); // exactly ONE child
        }
    }
}

The chaining rules trip people up in code review, so state them precisely:

  • A transaction may call System.enqueueJob up to 50 times
  • An executing Queueable may enqueue only one child job
  • Chain depth is unlimited in production, capped at 5 in Developer and Trial orgs
  • AsyncOptions.MaximumQueueableStackDepth lets you cap depth deliberately, and QueueableDuplicateSignature lets you block duplicate jobs from entering the queue

Transaction Finalizers — the retry mechanism Queueable was missing

A Finalizer runs after the Queueable completes, even when the job dies to an uncatchable limit exception. It is the only reliable place to implement retry:

public class SyncFinalizer implements Finalizer {
    public void execute(FinalizerContext ctx) {
        if (ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
            // Log, alert, or re-enqueue with backoff.
            // Max 5 consecutive re-enqueues from a finalizer.
        }
    }
}

Attach it with System.attachFinalizer(new SyncFinalizer()) inside execute(). If your Queueable performs callouts or DML that must not be silently lost, a Finalizer is not optional — it is the difference between a retried job and a support ticket three weeks later.

Batch Apex — for volume, not convenience

Batch exists for one reason: iterating over more records than a single transaction can hold. The start method returns a QueryLocator that can address 50 million records, and the platform feeds them to execute in scopes of up to 2,000 (default 200).

public class ContactRegionBatch implements Database.Batchable<SObject>, Database.Stateful {
    public Integer updated = 0; // Stateful: survives across scopes

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator(
            'SELECT Id, Region__c FROM Contact WHERE Region__c = null'
        );
    }

    public void execute(Database.BatchableContext bc, List<Contact> scope) {
        // Fresh governor limits per scope
        updated += scope.size();
    }

    public void finish(Database.BatchableContext bc) {
        // Chain follow-up work here
        System.enqueueJob(new NotifyJob(updated));
    }
}

Batch-specific rules worth committing to memory:

  • Only 5 batch jobs can be processing concurrently; up to 100 more wait in the flex queue. Job 106 fails to enqueue.
  • Each execute scope gets fresh governor limits — this is the feature. A 2M-record update is 10,000 independent 200-record transactions.
  • Database.Stateful preserves instance state across scopes, at a serialization cost on every scope boundary. Use it for counters and ID sets, not for dragging large collections through the run.
  • Calling @future from execute throws. Enqueue a Queueable instead.

The query powering start is where batch jobs over large data volumes live or die — an unselective QueryLocator against a 40M-row object can time out before the first scope runs.

Scheduled Apex — the trigger, not the worker

Scheduled Apex answers “when,” not “how much.” The execute method of a Schedulable runs under tight limits and is the wrong place for real work — its job is to start a Batch or enqueue a Queueable.

public class NightlySync implements Schedulable {
    public void execute(SchedulableContext ctx) {
        Database.executeBatch(new ContactRegionBatch(), 200);
    }
}
// Seconds Minutes Hours DayOfMonth Month DayOfWeek
System.schedule('Nightly sync', '0 0 2 * * ?', new NightlySync());

Two operational constraints: an org holds at most 100 scheduled jobs, and a class that is referenced by an active scheduled job is locked against deployment unless you abort the job first — a regular source of failed Friday deployments.

The decision framework

Work through these in order; the first match wins.

  1. Is it time-driven? → Scheduled Apex, delegating immediately to Batch or Queueable.
  2. Does it iterate more records than one transaction can process (roughly >10,000, or any unbounded set)? → Batch Apex.
  3. Everything else — callouts after DML, heavy computation, decoupling from a trigger context → Queueable with a Finalizer.
  4. Never → @future, unless you are maintaining code that already uses it.

A corollary for trigger code: triggers should never decide how async work runs. Route the decision through a handler layer — the trigger frameworks guide covers where that responsibility belongs.

Async governor limits compared

LimitSynchronousAll async contexts
CPU time10,000 ms60,000 ms
Heap size6 MB12 MB
SOQL queries100200
SOQL rows retrieved50,00050,000
DML statements150150
Callouts100100

The doubled CPU and heap are why “move it to async” genuinely fixes some limit exceptions — and why it merely postpones others. Rows, DML and callout ceilings do not move; if your synchronous code dies on 50,001 rows, Queueable dies on the same row.

Monitoring — query it, don’t click it

Setup → Apex Jobs shows the last 7 days, but production monitoring should be a query:

SELECT Id, JobType, Status, NumberOfErrors, ExtendedStatus,
       MethodName, CompletedDate
FROM AsyncApexJob
WHERE CreatedDate = TODAY AND Status IN ('Failed', 'Aborted')
ORDER BY CreatedDate DESC

Wire that into a scheduled health check that posts failures to your alerting channel. The orgs that discover async failures from users are the orgs that never wrote this query.

The five mistakes that reach production

  1. Chaining Queueables without a depth guard — works in the happy path, then a data condition makes the chain self-perpetuating and consumes the daily async allowance. Pass a depth counter or set MaximumQueueableStackDepth.
  2. @future for callouts after DML — it works until someone needs the result, a retry, or ordering. Queueable from day one costs nothing extra.
  3. Batch scope of 2,000 with callouts — 100 callouts per transaction is the ceiling regardless of scope size; a 2,000-record scope needing one callout each fails on record 101. Size scope to the callout budget.
  4. Stateful batches carrying heavy collections — every scope boundary serializes the whole instance. Carry IDs and counters; re-query everything else.
  5. No Finalizer on business-critical Queueables — an uncatchable limit exception loses the work invisibly. Finalizers exist precisely for this.

Async Apex is not about escaping governor limits — it is about choosing which limits you operate under, and building the monitoring to know when you hit them.

Test your knowledge — Apex

10 questions · Basic to Advanced

0 / 10 correct

Advertisement

Frequently asked questions

When should I use Queueable instead of @future in Apex?

Almost always. Queueable supports complex object parameters, returns a job ID for monitoring, allows chaining, and supports Transaction Finalizers. @future remains only for legacy code and the narrow case of simple fire-and-forget callouts in old codebases.

What is the difference between Batch Apex and Queueable Apex?

Batch Apex is built for processing large record volumes in chunks with its own start/execute/finish lifecycle, while Queueable runs a single asynchronous unit of work. If you are iterating millions of records, use Batch; if you are offloading one transaction's worth of work, use Queueable.

How many Queueable jobs can I chain in Salesforce?

In production orgs chain depth is unlimited, but each executing Queueable may enqueue only one child job. Developer and Trial orgs cap chain depth at 5. You can also limit depth explicitly with AsyncOptions.MaximumQueueableStackDepth.

Can I call a @future method from Batch Apex?

No. Calling a @future method from a batch execute method throws a runtime exception. Enqueue a Queueable from the batch instead — Queueable jobs can be started from batch context.

What are Transaction Finalizers in Apex?

A Finalizer attaches to a Queueable job and runs after it completes, whether it succeeded or failed — including on uncatchable failures like limit exceptions. It is the only reliable way to implement retry logic for Queueable jobs.

What is the default batch size in Batch Apex?

200 records per execute invocation. You can pass a different scope size up to 2,000 to Database.executeBatch, but for callout-heavy batches a smaller scope is usually safer.

Advertisement