Queueable vs Batch Apex vs Future: When to Use What

"Should I use Queueable, Batch Apex, or Future?"
 This is one of the most common questions every Salesforce developer asks at some point. While all three are used for asynchronous processing, they serve different use cases, limits, and patterns.

We’ll break down when to use each async tool, with real code examples, performance comparisons, and gotchas I’ve learned from years in the trenches.


Why Use Asynchronous Apex?


Asynchronous Apex allows you to run long-running operations in the background, freeing up system resources and improving user experience.

Common use cases:

  • Processing thousands of records
  • Calling external services
  • Sending bulk emails
  • Updating related data in complex logic

Why Async Matters in Salesforce


If you've ever hit a "Too many SOQL queries" or "CPU time limit exceeded" error, you already know why asynchronous processing is a big deal. Salesforce enforces strict governor limits to keep shared resources stable, but that also means you can't always get everything done in a single transaction.

That's where @future, Queueable, and Batch Apex come in. They're all tools for handling logic after the current transaction finishes?-?but they serve very different purposes.


Quick Comparison: What’s the Difference?


Feature @future Queueable Apex Batch Apex
Governor Limits Same as caller Higher limits Highest limits
Chaining No Yes No
Statefulness Stateless Stateful Stateful
Record Scope 1 ID/record 1 ID/record 50K-50M records
Best For Quick async ops Complex async chains Large data volumes


1. @future: The Simple (But Limited) Option


Before Queueable Apex existed, @future was the go-to for async work. It still has its place, but it's limited.

When to Use:

  • Simple, one-off async tasks (e.g., logging, basic callouts)
  • Maintaining older code where refactoring isn't feasible

Example: Sending a Callout After Account Update


public class AccountService {
    public static void updateAccount(Id accountId) {
        // Immediate DML
        Account acc = [SELECT Id, Name FROM Account WHERE Id = :accountId];
        acc.Name += ' (Updated)';
        update acc;
        
        // Async callout
        makeCallout(acc.Id);
    }
    
    @future(callout=true)
    public static void makeCallout(Id accountId) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('https://api.example.com/update');
        req.setMethod('POST');
        new Http().send(req); // Callout won't block main transaction
    }
}

Output:

  • Sync: Account name updates instantly
  • Async: Callout happens later (won't fail if DML takes too long)

Limitations:

  • No chaining (can't call another @future from it)
  • Future Methods can only return void type
  • Only accepts primitive parameters (no sObjects or lists)
  • Harder to debug?-?not visible in the Apex Jobs UI

2. Queueable Apex — Future’s Smarter Cousin


Queueable is like Future++ — it gives you more control, supports chaining, and can pass complex objects.

When to Use:

  • Post-trigger logic (e.g., enrich records, send emails, kick off follow-up jobs)
  • Chained workflows (e.g., update ---> callout ---> notify ---> status update)
  • Complex data passing (lists, sObjects, custom classes)
  • Anything that used to be a @future but needs more flexibility

Example: Multi-Step Account Processing


public class AccountEnricher implements Queueable, Database.AllowsCallouts {
    private List accountIds;
    
    // Constructor preserves state
    public AccountEnricher(List accountIds) {
        this.accountIds = accountIds; 
    }
    
    public void execute(QueueableContext ctx) {
        // Step 1: Enrich account data
        List accounts = [SELECT Id, Website FROM Account WHERE Id IN :accountIds];
        for (Account acc : accounts) {
            acc.Industry = inferIndustry(acc.Website);
        }
        update accounts;
        
        // Step 2: Chain a follow-up job
        if (!accounts.isEmpty()) {
            System.enqueueJob(new ERPNotifier(accountIds)); // One chain allowed
        }
    }
    
    private String inferIndustry(String website) {
        if (website == null) return 'Other';
        if (website.contains('.edu')) return 'Education';
        if (website.contains('.tech')) return 'Technology';
        return 'Other';
    }
}

How to Call It From a Trigger Handler:

Never heard of a trigger handler? Here’s my guide on how to set one up.

Trigger Handler Integration:


public class AccountTriggerHandler {
    public static void handleAfterInsert(List newAccounts) {
        List accountIds = new List();
        for (Account acc : newAccounts) {
            if (acc.Website != null) {
                accountIds.add(acc.Id);
            }
        }
        if (!accountIds.isEmpty()) {
            System.enqueueJob(new AccountEnricher(accountIds));
        }
    }
}

Then, in your trigger:


trigger AccountTrigger on Account (after insert) {
 AccountTriggerHandler.handleAfterInsert(Trigger.new);
}

Limitations:

  • Job enqueuing limits:
  • In a synchronous context (like a trigger or controller), you can enqueue up to 50 Queueable jobs per transaction.

    In an asynchronous context (like another Queueable or Batch job), you can enqueue only one job per transaction.

  • Chaining jobs:
  • You can chain Queueable jobs (job ---> job ---> job…), but each job can only enqueue one child.

    There’s no hard system limit on the number of chained jobs — except in Developer Edition orgs, where you’re capped at a max chain depth of 5 jobs total.


Key Advantages Over @future:

  • Preserves state (instance variables)
  • Chainable workflows (one job can enqueue another)
  • Visible in Apex Jobs UI
  • Accepts complex parameters

Warning: You can only chain one job per execution in async contexts.


3. Batch Apex – The Heavyweight Champion


Batch Apex is made for large datasets — up to 50 million records. It’s your go-to tool for nightly jobs, data cleanup, and more.

When to Use:

  • Processing 50K+ records (e.g., data migrations)
  • Scheduled operations (e.g., nightly syncs, score recalculations)
  • High-volume updates or deletes

Each execute method gets its own transaction, so governor limits are reset per chunk — but that also means it's slower and more transactional than Queueable.

Example: Nightly Account Cleanup


global class AccountCleanupBatch implements Database.Batchable {
    global Database.QueryLocator start(Database.BatchableContext bc) {
        // Query all accounts needing cleanup
        return Database.getQueryLocator(
            'SELECT Id, LastActivityDate FROM Account WHERE LastActivityDate < LAST_N_DAYS:90'
        );
    }
    
    global void execute(Database.BatchableContext bc, List scope) {
        // Delete stale accounts
        delete scope;
    }
    
    global void finish(Database.BatchableContext bc) {
        // Optional: Send email report
        Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
        mail.setSubject('Batch Complete');
        Messaging.sendEmail(new List{mail});
    }
}

Execution:


// Run batch (200 records at a time)
Id batchId = Database.executeBatch(new AccountCleanupBatch(), 200);

Output:

  • Processes millions of records in chunks
  • Avoids governor limits (separate limits per batch)

Batch-Specific Benefits:

  • Governor limits reset per batch
  • Handles millions of records
  • Built-in scheduling

Performance Tip: Test different batch sizes (start with 200) to balance speed vs. limits.


Real-World Lessons Learned


Lesson 1: @future Deadlocks

Problem: Calling @future from a trigger can hit async limits (max 50 per transaction).

Fix: Use Queueable instead—it has higher limits.


Lesson 2: Queueable Chain Depth

Problem: Chaining too many jobs fails in Dev Edition orgs (max 5 chains).

Fix: Track depth in a custom object or setting.


Lesson 3: Batch Timeouts

Problem: Long-running batches fail at 10 minutes per execute().

Fix:

  • Reduce batch size
  • Optimize SOQL in loops

Real-Life Use Case Comparison


Scenario Best Tool Why
Send welcome email on contact insert Future Lightweight, fire-and-forget
Update related records in chain Queueable Can chain and track
Clean up 1 million old records Batch Apex Handles massive volumes
Process complex input objects Queueable Supports custom types
Monitor job status in UI Queueable / Batch Shown in Apex Jobs tab


Performance Benchmarks


Test: Process 10,000 Account records

Method Time Governor Limit Safety
@future Fail Poor (max 50 calls/transaction)
Queueable 5 min Good (higher limits)
Batch (size 200) 3 min Best (limits reset per batch)


Final Thoughts

Salesforce gives us three main tools for async logic — and each one has its place:

  • Use @future for quick, simple jobs.
  • Use Queueable for flexibility, chaining, and passing rich data.
  • Use Batch Apex when you’re working with huge data volumes or scheduled cleanups.

The best developers don’t just know the syntax — they know when and why to reach for each tool.

Your Turn:
Which async tool saved your project? Share your story below!

Share This Post:

About The Author

Hey, my name is Saurabh Samir, and I am a Salesforce Developer with a passion for helping you elevate your knowledge in Salesforce, Lightning Web Components (LWC), Salesforce triggers, and Apex. I aim to simplify complex concepts and share valuable insights to enhance your Salesforce journey. Do comment below if you have any questions or feedback—I'd love to hear from you!