SapotaCorp

Async Apex Selection: Queueable, Batch, Future, or Scheduled

Salesforce ships four async Apex patterns. Each has a specific shape, a specific governor limit profile, and a specific failure mode. Choosing the wrong one produces code that works at low volume and fails at scale.

Key takeaways

  • Salesforce ships 4 async Apex patterns. Each has a specific shape and a specific failure mode. Future is the oldest and most limited (no chaining, no callouts, fire-and-forget). Queueable is the modern default (chainable, type-safe, supports complex state). Batch handles high-volume (200K+ records). Scheduled fires at defined times.
  • Default to Queueable for new work. The pattern handles 90 percent of async needs: passing state across invocations, chaining stages, callouts to external systems, error handling. The old Future pattern remains for compatibility but rarely the right new-code choice.
  • Batch Apex is right when the work crosses 200K records. The Batch chunks (default 200 records per execute) provide governor-limit-safe iteration; start() returns the query scope, execute() processes chunks, finish() handles cleanup. Complex but the only path for very-large-volume async.
  • Scheduled Apex is rarely the right tool. It exists for time-driven jobs (nightly recalculations, monthly cleanup); most teams reach for Scheduled when they should use Platform Events or queue-driven Queueables. Scheduled jobs are fragile, hard to test, and prone to "the schedule ran 3 hours late" incidents.
Async Apex Selection: Queueable, Batch, Future, or Scheduled

Salesforce ships four ways to run Apex outside the current transaction: Future methods, Queueable, Batch Apex, and Scheduled Apex. Each has a specific shape, a specific governor limit profile, and a specific failure mode. Engineers new to Salesforce often pick whichever pattern they encountered first. The result is code that "works" in a developer org with 10 records and falls over in production with thousands.

The selection question is not academic. The wrong choice locks the implementation into a pattern that has to be refactored later, often under time pressure when the failure mode emerges.

Future methods: the legacy default

@future is the oldest async pattern in Apex. The method is annotated, accepts only primitive types, and runs asynchronously some time after the calling transaction commits.

public class AccountService {
  @future
  public static void syncToExternal(Set<Id> accountIds) {
    // ... runs async ...
  }
}

When to use Future today: almost never for new code. It was the only option before Queueable shipped in 2015, so a lot of legacy code uses it. New implementations should prefer Queueable.

Why avoid Future:

  • Cannot accept sObject parameters. Only primitives, which forces ID lists and a re-query.
  • Cannot be chained or sequenced.
  • No way to query its execution status programmatically.
  • Limited to 50 calls per transaction.
  • No retry on failure.

The one case where Future still applies: synchronous callouts that must not block the current transaction, where the simpler pattern is acceptable. Even then, Queueable with the Database.AllowsCallouts interface is more flexible.

Queueable: the modern default

Queueable is the recommended async pattern for most new Apex work. The class implements Queueable and is enqueued with System.enqueueJob(...).

public class AccountSyncQueueable implements Queueable, Database.AllowsCallouts {
  private Set<Id> accountIds;

  public AccountSyncQueueable(Set<Id> ids) {
    this.accountIds = ids;
  }

  public void execute(QueueableContext qc) {
    // ... runs async ...
    // Can chain: System.enqueueJob(new NextStepQueueable(...));
  }
}

What Queueable does well:

  • Accepts complex types (sObjects, custom classes) via constructor.
  • Can chain itself or other Queueables for multi-step async workflows.
  • Job ID returned by enqueueJob is queryable via AsyncApexJob.
  • Supports callouts (with Database.AllowsCallouts).
  • Higher async governor limits than synchronous context.

When to use Queueable:

  • Any async work involving sObjects or complex types.
  • Sequential async steps where step N depends on step N-1.
  • Callouts that should not block the user's transaction.
  • Most "fire and continue" patterns.

What Queueable does not do well:

  • Volumes above 50,000 records in one job. Use Batch Apex instead.
  • Scheduled execution at fixed times. Use Scheduled Apex.

Batch Apex: high-volume work

Batch Apex processes large data sets by chunking. The class implements Database.Batchable<sObject> with three methods: start, execute, and finish. The framework calls start once to define the data set, then calls execute repeatedly with chunks of 200 records (configurable up to 2,000).

public class CaseAgingBatch implements Database.Batchable<sObject> {
  public Database.QueryLocator start(Database.BatchableContext bc) {
    return Database.getQueryLocator(
      'SELECT Id, Status FROM Case ' +
      'WHERE CreatedDate < LAST_N_DAYS:90'
    );
  }

  public void execute(Database.BatchableContext bc, List<Case> scope) {
    // Process up to 200 cases per execution.
  }

  public void finish(Database.BatchableContext bc) {
    // Optional cleanup, notifications, chained job kickoff.
  }
}

// Run:
Database.executeBatch(new CaseAgingBatch(), 200);

When to use Batch Apex:

  • Operations on more than 50,000 records.
  • Data migration cleanup that must complete reliably.
  • Nightly maintenance jobs (often combined with Scheduled).
  • Anything that would breach synchronous heap or CPU limits.

What Batch Apex does well:

  • Handles up to 50 million records per job.
  • Each chunk runs in its own transaction with its own governor limits.
  • Resumable: a failed chunk does not roll back the others.
  • Higher async governor limits.

What Batch Apex does poorly:

  • Latency. Jobs are queued and may not start immediately, especially when the org is busy.
  • Per-record processing. The 200-record chunk size is a sweet spot, but operations that genuinely process records individually inside the chunk hit per-execution limits.
  • Concurrent runs. Only 5 active or queued batch jobs allowed simultaneously (with some exceptions). High-frequency batch scheduling can hit this limit.

Scheduled Apex: time-driven execution

Scheduled Apex fires at defined times. The class implements Schedulable and is scheduled via the UI or System.schedule(...).

public class NightlyCleanupSchedule implements Schedulable {
  public void execute(SchedulableContext sc) {
    // Trigger Batch Apex from inside the Schedulable.
    Database.executeBatch(new CaseAgingBatch(), 200);
  }
}

// Schedule:
String cron = '0 0 2 * * ?'; // 2am every day
System.schedule('Nightly Case Aging', cron,
                new NightlyCleanupSchedule());

When to use Scheduled Apex:

  • Recurring jobs at fixed times (nightly, weekly, monthly).
  • Time-based triggers that the platform's standard Time-Based Workflows cannot handle.

Scheduled Apex is rarely the workhorse itself. It is typically used to fire off a Batch or Queueable that does the actual work. Keep Schedulable.execute short.

What to watch:

  • Cron strings are tricky to get right. Test the schedule with System.schedule in a sandbox before relying on it in production.
  • Scheduled jobs run as the user who scheduled them. Permission gotchas if that user lacks access to objects the job touches.
  • Maximum 100 scheduled jobs total per org. Heavy use of System.schedule in dynamic patterns can hit this limit.

A decision tree

For each new async requirement, run through:

  1. Is the work fixed-schedule (nightly, weekly)? → Scheduled Apex (kicking off Batch or Queueable).
  2. Is the data volume above 50,000 records? → Batch Apex.
  3. Does the work involve sObjects, callouts, or chained steps? → Queueable.
  4. Is this legacy code that already uses Future? → Leave it for now; refactor opportunistically.
  5. None of the above? → Queueable is the safe default.

Governor limit differences

Each async context has different limits. The ones that matter most:

Context SOQL queries DML statements CPU time
Synchronous 100 150 10,000ms
@future 200 150 60,000ms
Queueable 200 150 60,000ms
Batch Apex (per execute) 200 150 60,000ms

The async contexts roughly double the SOQL limit and 6x the CPU time. This is why moving expensive synchronous work to async often resolves governor limit issues even when nothing else changes.

Common async mistakes

Five patterns Sapota has seen in audits:

  • Future overused. Legacy orgs are full of @future methods that should have been Queueable. The cost is operational; the code keeps working until something requires chaining or sObject parameters.
  • Queueable used for huge volumes. A Queueable that tries to process 100,000 records in one execute. Hits async limits. Use Batch.
  • Batch Apex for small volumes. A Batch job that processes 1,000 records. The framework overhead is wasted. Use Queueable.
  • Scheduled Apex doing the work directly. A schedulable's execute method runs business logic synchronously. Hits limits at scale. Schedulable should kick off Batch or Queueable.
  • Async without testability. Async methods without Test.startTest/Test.stopTest boundaries in their tests. The async work never runs during the test, coverage is misleading.

What good async architecture looks like

A Salesforce org with healthy async patterns:

  • New code defaults to Queueable for async work.
  • @future code marked for opportunistic refactor when touched.
  • Batch Apex used for operations clearly above 50K records.
  • Scheduled Apex used for time-based kickoff, not for the work itself.
  • Async methods tested with Test.startTest/Test.stopTest to force execution.
  • AsyncApexJob queried for observability and operational dashboards.

Sapota's Salesforce team holds the Platform Developer I credential and selects async patterns deliberately during architecture design, not after the first scale incident. The selection has long-lived consequences for code maintainability and operational behavior.


Designing async Apex patterns or refactoring legacy @future code? Sapota's Salesforce team handles async architecture, batch processing design, and scheduled job operations on production engagements. Get in touch ->

See our full platform services for the stack we cover.