← Back to Platform & Technical
PLAT-025 Platform & Technical 22 min read For: Architects

Async Apex Deep Dive: Batch, Queueable, Future, and Schedulable

The four async Apex mechanisms and when each is the right tool, the governor limits specific to each type, Queueable chaining patterns, Batch Apex cursor and stateful patterns, and the common mistakes that lead to production failures.

VS

Vishal Sharma

Salesforce Architecture Specialist · Updated May 2026

What You'll Learn

The four async Apex mechanisms and when each is the right tool, the governor limits specific to each type, Queueable chaining patterns, Batch Apex cursor and stateful patterns, and the common mistakes that lead to production failures.

Why Async Apex Exists

Synchronous Apex transactions have hard governor limits: 100 SOQL queries, 150 DML statements, 10 MB heap, 10 second CPU time. These limits prevent any single transaction from monopolising the multi-tenant infrastructure. Async Apex runs outside the synchronous transaction, with its own fresh set of limits, enabling long-running and high-volume operations that synchronous code cannot handle.

The four async mechanisms are: Future methods, Queueable Apex, Batch Apex, and Schedulable Apex. Each has a different use case, different limits, and different capabilities.

Future Methods: Simple Fire-and-Forget

Future methods are the simplest async mechanism — annotate a static method with @future and Salesforce runs it in a separate transaction after the current one commits. They accept only primitive parameters (no SObjects, no collections of SObjects) because the current transaction's heap is not available in the async context.

// Future method — simple async callout
@future(callout=true)
public static void syncToERP(Set<Id> accountIds) {
  List<Account> accounts = [SELECT Id, Name, Phone
                              FROM Account WHERE Id IN :accountIds];
  for (Account acct : accounts) {
    // Make callout for each account
    ERPService.syncAccount(acct);
  }
}

// Limitations:
// - No SObject parameters (pass Ids, query inside)
// - Cannot monitor or chain
// - Max 50 future calls per transaction
// - No Stateful interface

Queueable Apex: Chainable and Monitorable

Queueable Apex was introduced to address future method limitations. A class implementing Queueable can accept complex parameters (SObjects, custom objects), can chain to the next job in its execute() method, and returns a job ID that can be monitored via SOQL.

// Queueable with chaining
public class ProcessOrdersJob implements Queueable, Database.AllowsCallouts {
  private List<Order__c> orders;
  private Integer batchIndex;

  public ProcessOrdersJob(List<Order__c> orders, Integer batchIndex) {
    this.orders = orders;
    this.batchIndex = batchIndex;
  }

  public void execute(QueueableContext ctx) {
    // Process this batch of orders
    Integer endIdx = Math.min(batchIndex + 50, orders.size());
    processOrders(orders.subList(batchIndex, endIdx));

    // Chain to next batch if more to process
    if (endIdx < orders.size()) {
      System.enqueueJob(new ProcessOrdersJob(orders, endIdx));
    }
  }
}

// Monitor via SOQL
AsyncApexJob job = [SELECT Id, Status, NumberOfErrors
                    FROM AsyncApexJob
                    WHERE Id = :jobId];
Key Point Queueable chaining depth is limited to 50 levels in production (200 in sandbox). For deeper chains, switch to Batch Apex. Also: in test contexts, Queueable jobs are synchronous by default — wrap in Test.startTest() / Test.stopTest() to process the queue.

Batch Apex: High-Volume Record Processing

Batch Apex processes records in configurable chunks (default 200, max 2,000 per chunk). Salesforce calls execute() once per chunk with fresh governor limits for each chunk. A batch job can process tens of millions of records by breaking them into manageable pieces.

public class LeadScoreBatch implements Database.Batchable<SObject>,
                                        Database.Stateful {
  // Stateful: instance variables persist across execute() calls
  public Integer processedCount = 0;
  public Integer errorCount = 0;

  // start(): returns a QueryLocator (max 50M records) or Iterable
  public Database.QueryLocator start(Database.BatchableContext bc) {
    return Database.getQueryLocator(
      'SELECT Id, Email, LastActivityDate FROM Lead WHERE IsConverted = false'
    );
  }

  // execute(): called once per chunk with fresh governor limits
  public void execute(Database.BatchableContext bc, List<Lead> scope) {
    List<Lead> toUpdate = new List<Lead>();
    for (Lead l : scope) {
      l.LeadScore__c = calculateScore(l);
      toUpdate.add(l);
      processedCount++;
    }
    try {
      update toUpdate;
    } catch (Exception e) {
      errorCount++;
    }
  }

  // finish(): called once after all chunks complete
  public void finish(Database.BatchableContext bc) {
    // Send completion notification
    sendSummaryEmail(processedCount, errorCount);
  }
}
Warning Callouts (HTTP requests) are not allowed in Batch Apex unless the class implements Database.AllowsCallouts. Even with that interface, callouts are limited to one per execute() chunk — you cannot make 200 callouts in a 200-record chunk. Design patterns that batch callout work separately from DML work.

Choosing the Right Pattern

Decision framework:

  • Use @future: Simple fire-and-forget operations with primitive data, single callout, no chaining needed. Legacy code maintenance — prefer Queueable for new code.
  • Use Queueable: Complex object parameters, callouts required, chaining to next job, need to monitor job ID. Best for workflows that need state passed between steps.
  • Use Batch Apex: Processing more than a few thousand records, need stateful accumulation across chunks, need finish() callback, record set fits a SOQL query.
  • Use Schedulable: Time-based triggers only — always delegates actual work to Batch or Queueable.
Tip Monitor production async jobs proactively. Set up a daily Apex job that queries AsyncApexJob for any jobs with Status = 'Failed' in the past 24 hours and emails the admin team. Silent job failures are the most common undetected production issue in large orgs.

Key Takeaways

  • Future methods: simple, limited to primitives, no chaining — best for legacy or simple fire-and-forget.
  • Queueable: complex parameters, chainable, monitorable — the modern default for most async needs.
  • Batch Apex: high-volume record processing in chunks, fresh governor limits per chunk, Stateful for cross-chunk accumulation.
  • Schedulable: time trigger only — always delegates work to Batch or Queueable, never processes records directly.
  • Queueable chaining limit is 50 in production — use Batch Apex for deeper processing chains.
  • Monitor AsyncApexJob for failures proactively — silent job failures are the most common undetected production issue.

Check Your Understanding

1. A process needs to make an HTTP callout for each of 5,000 records. Which async mechanism is appropriate?

A. @future method with a loop — it supports callouts and can handle any number of records
B. Batch Apex with Database.AllowsCallouts — it makes one callout per chunk with fresh governor limits, enabling high-volume callout processing
C. Scheduled Flow — it can make callouts via External Services without governor limit constraints

2. A Batch Apex job needs to count total records processed and errors across all chunks and report them in finish(). What interface is required?

A. Database.AllowsCallouts — it enables inter-chunk state sharing
B. Database.Stateful — it preserves instance variable state across execute() calls
C. No interface needed — instance variables persist automatically in Batch Apex

3. A Queueable chain needs to process 60 levels of chained operations in production. What is the issue?

A. No issue — Queueable chains have no depth limit in production
B. The 50-level chaining limit will halt execution — refactor to Batch Apex for processing depth beyond 50 levels
C. Queueable chains over 50 levels require a special Extended Queueable licence

Discussion & Feedback