Asynchronous Apex in Salesforce: Complete Guide (2026)

Have you ever hit a governor limit mid-transaction and thought, “this really shouldn’t be happening synchronously”? You’re not alone. Async Apex is one of those topics every Salesforce developer learns early but rarely masters fully — and the difference between a developer who understands it deeply and one who doesn’t shows up fast in complex orgs.

Asynchronous Apex lets you run Apex code outside the normal execution context — in the background, in parallel, or on a schedule. This frees up your synchronous transactions, gives you higher governor limits, and makes integrations far more resilient.

This guide covers every async Apex method available in Salesforce: Future Methods, Queueable Apex, Batch Apex, and Scheduled Apex. You’ll learn what each one is, when to use it, how they compare, and the best practices that will save you hours of debugging.

Let’s get into it.


Table of Contents

  1. Why Asynchronous Processing?
  2. Types of Asynchronous Apex
  3. Comparison Table: All Async Methods
  4. Choosing the Right Async Method
  5. Future Methods Quick Guide
  6. Queueable Apex Quick Guide
  7. Batch Apex Quick Guide
  8. Scheduled Apex Quick Guide
  9. Best Practices Across All Types
  10. Testing Asynchronous Apex
  11. FAQ

Why Asynchronous Processing?

Synchronous Apex is fast and simple, but it comes with hard limits — 10 seconds of CPU time, 100 SOQL queries, 150 DML operations. When you’re processing large data volumes, calling external APIs, or running long-running jobs, those limits become walls you run into repeatedly.

Asynchronous processing solves this by moving work out of the user’s transaction and into a background queue. The user doesn’t wait. The org doesn’t choke. And you get a much bigger sandbox of governor limits to work in.

Benefits of Async Processing

  • Higher governor limits — Async Apex runs with a fresh set of limits. Batch Apex, for example, allows 50 million records per execute call.
  • Better user experience — Users don’t have to sit and wait for heavy processing to complete.
  • Resilience — Jobs can be retried if they fail, and you can monitor them via the Apex Jobs UI.
  • Scalability — Processes that would time out synchronously run cleanly in the background.
  • Decoupled architecture — Async methods let you separate concerns, making code easier to maintain and test.

When to Go Asynchronous

You should reach for async Apex when:

  • You’re making callouts to external services (required by Salesforce — callouts can’t happen in trigger context unless async)
  • You’re processing more than a few hundred records
  • Your logic takes more than a few seconds and risks hitting CPU limits
  • You need to chain complex operations in sequence
  • You want to schedule recurring jobs (nightly syncs, daily reports, etc.)

A good mental model: if the work doesn’t need to be done right now in front of the user, it’s probably better async.

“In Salesforce development, the question isn’t whether to use async Apex — it’s which async method fits your use case.”


Types of Asynchronous Apex

Salesforce gives you four async Apex tools, each designed for a different job. Understanding the difference is what separates solid Apex developers from great ones.

Future Methods Overview

Future methods are the simplest form of async Apex. You annotate a static method with @future and Salesforce runs it in a separate thread, asynchronously.

They’re ideal for one-off background tasks — particularly callouts from trigger context. If you’ve ever seen the error “You have uncommitted work pending. Please commit or rollback before calling out,” a future method is usually the fix.

Key characteristics:

  • Must be static and void
  • Parameters must be primitive types or collections of primitives (no sObjects)
  • Can make HTTP callouts (use @future(callout=true))
  • Limited monitoring and no chaining capability

Queueable Apex Overview

Queueable Apex is the evolved version of future methods. It implements the Queueable interface and gives you much more control: you can pass complex objects, chain jobs together, and get a job ID back for monitoring.

In my experience, Queueable is the workhorse of async Apex. Any time you need a future method but want to pass an sObject, chain another job, or handle more complex state, Queueable is the answer.

Key characteristics:

  • Implements Queueable interface with an execute method
  • Accepts any object type as parameter
  • Returns a job ID via System.enqueueJob()
  • Supports chaining (up to 5 levels in a single transaction in sandbox; 1 in production triggers)
  • Can implement Database.AllowsCallouts for HTTP callouts

Batch Apex Overview

Batch Apex is designed for processing large data volumes — think tens of thousands to millions of records. It divides your dataset into manageable chunks (called “batches”), processes each one independently, and then runs a final consolidation step.

The three-phase lifecycle — start(), execute(), finish() — makes Batch Apex predictable and easy to structure. Each execute() call gets fresh governor limits.

Key characteristics:

  • Implements Database.Batchable interface
  • Processes records in configurable batch sizes (default 200, max 2000)
  • Each batch gets its own governor limit context
  • Can process up to 50 million records
  • finish() method runs after all batches complete (great for sending notifications)

Scheduled Apex Overview

Scheduled Apex lets you run code on a recurring schedule — hourly, daily, weekly, or any cron expression you define. It implements the Schedulable interface and has a single execute() method.

You’ll often combine it with Batch Apex: the Scheduler kicks off a batch job at a set time. That’s a pattern used in almost every Salesforce org with any serious data management.

Key characteristics:

  • Implements Schedulable interface
  • Scheduled via cron expression or System.schedule()
  • Up to 100 scheduled jobs per org
  • Can be scheduled through both code and the Salesforce UI
  • Often used as a “trigger” to kick off Batch jobs

Comparison Table: All Async Methods

FeatureFuture MethodQueueable ApexBatch ApexScheduled Apex
Interface/Annotation@futureQueueableDatabase.BatchableSchedulable
ComplexityLowMediumMedium-HighLow
Chaining❌ No✅ Yes (5 deep)❌ No❌ No
Complex Object Params❌ No✅ Yes✅ Yes✅ Yes
Callouts✅ Yes✅ With interface✅ With interface❌ Indirectly
MonitoringLimited✅ Job ID✅ Full✅ Full
Max RecordsN/AN/A50 millionN/A
Scheduling❌ No❌ No❌ No✅ Yes
Use CaseSimple calloutsFlexible asyncLarge data setsRecurring jobs
SOQL Limit per transaction100100200 per batch100
Heap Size12 MB12 MB12 MB per batch12 MB

Use Cases for Each Type

Future Methods are best for:

  • Callouts from trigger context
  • Simple fire-and-forget background updates
  • Mixed DML operations (updating setup and non-setup objects)

Queueable Apex is best for:

  • Passing rich objects or state between async steps
  • Chained multi-step background processing
  • Callouts with complex processing logic afterward
  • When you need to track job status programmatically

Batch Apex is best for:

  • Mass data updates or migrations
  • Daily/nightly data cleanup jobs
  • Complex calculations over large record sets
  • Any process working with tens of thousands of records or more

Scheduled Apex is best for:

  • Nightly sync jobs
  • Recurring maintenance tasks
  • Triggering batch jobs on a schedule
  • Regular data exports or report generation

Governor Limits Comparison

Each async method runs with its own governor limit context. Here’s what matters most:

LimitFutureQueueableBatch (per execute)Scheduled
SOQL queries100100200100
DML statements150150150150
CPU time60,000 ms60,000 ms60,000 ms60,000 ms
Heap size12 MB12 MB12 MB12 MB
Callouts1001001000 (direct)

The big advantage with Batch Apex isn’t higher per-limit numbers — it’s that each batch chunk resets the limits. That’s how you process millions of records without hitting walls.


Choosing the Right Async Method

Picking the wrong async method is one of the most common mistakes I see in Salesforce orgs — usually it’s a future method used where Queueable would work better, or a Queueable struggling with record volumes that Batch was built for.

Here’s a simple decision framework:

  1. Do you need to run on a schedule? → Use Scheduled Apex (often to trigger a Batch)
  2. Are you processing 1,000+ records? → Use Batch Apex
  3. Do you need to chain steps or pass complex objects? → Use Queueable Apex
  4. Is it a simple one-off callout or background task? → Use a Future Method

One thing worth noting: future methods are often overused in legacy orgs. If you find yourself writing a future method that needs to accept an sObject, or that you wish you could chain — stop. Refactor to Queueable. You’ll thank yourself later.


Future Methods Quick Guide

Apex Code

public class LeadIntegrationService {

    @future(callout=true)
    public static void syncLeadToExternalSystem(Id leadId) {
        Lead lead = [SELECT Id, Name, Email FROM Lead WHERE Id = :leadId];
        
        HttpRequest req = new HttpRequest();
        req.setEndpoint('https://api.externalcrm.com/leads');
        req.setMethod('POST');
        req.setBody(JSON.serialize(lead));
        
        Http http = new Http();
        HttpResponse res = http.send(req);
        
        if (res.getStatusCode() != 200) {
            // Handle error — log or retry logic here
        }
    }
}

Rules to remember with future methods:

  • Parameters must be primitives — Id, String, Integer, List<Id>, etc. You can’t pass an sObject directly. Pass the ID and re-query inside the method.
  • You can’t call a future method from another future method. If you need chaining, use Queueable.
  • You can’t call System.isFuture() to check context — use System.isFuture() before invoking to avoid nested future calls.
  • Future methods have no job ID returned, so monitoring is limited to debug logs and error emails.

When a future method is the right call (pun intended): Use it when you need to make an HTTP callout from a trigger, or when you have a simple fire-and-forget task that doesn’t need complex parameters or monitoring. Or some operation which doesn’t need to happen in the same transaction.


Queueable Apex Quick Guide

Apex Code

public class AccountEnrichmentJob implements Queueable, Database.AllowsCallouts {
    
    private List<Id> accountIds;
    
    public AccountEnrichmentJob(List<Id> accountIds) {
        this.accountIds = accountIds;
    }
    
    public void execute(QueueableContext context) {
        List<Account> accounts = [
            SELECT Id, Name, Website FROM Account WHERE Id IN :accountIds
        ];
        
        for (Account acc : accounts) {
            // Enrich account data via callout or complex logic
        }
        
        update accounts;
        
        // Chain the next job if needed
        if (needsFurtherProcessing()) {
            System.enqueueJob(new AccountNotificationJob(accountIds));
        }
    }
    
    private Boolean needsFurtherProcessing() {
        return true; // Your logic here
    }
}

// Invoke it:
// System.enqueueJob(new AccountEnrichmentJob(accountIdList));

Chaining pattern — use it carefully:

Chaining Queueable jobs is powerful but limited to 5 levels deep from a single transaction in sandbox (1 from a trigger in production). Don’t build infinitely recursive chains — that’s how you create runaway job queues.

A common pattern is to pass a “step” counter into the constructor so you always know how deep in the chain you are, and bail out gracefully if something goes wrong.

When Queueable beats future methods every time:

  • You need to pass an sObject or custom object
  • You want the job ID to monitor via AsyncApexJob
  • You need to chain to another async operation
  • Your logic is complex enough to warrant a proper class structure

Batch Apex Quick Guide

Apex Code

public class OpportunityCleanupBatch implements Database.Batchable<sObject> {
    
    // start() — defines the scope (what records to process)
    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator([
            SELECT Id, StageName, CloseDate 
            FROM Opportunity 
            WHERE IsClosed = false AND CloseDate < TODAY
        ]);
    }
    
    // execute() — processes each chunk of records
    public void execute(Database.BatchableContext bc, List<Opportunity> scope) {
        for (Opportunity opp : scope) {
            opp.StageName = 'Closed Lost';
        }
        update scope;
    }
    
    // finish() — runs once after all batches complete
    public void finish(Database.BatchableContext bc) {
        AsyncApexJob job = [
            SELECT Id, Status, NumberOfErrors, TotalJobItems
            FROM AsyncApexJob WHERE Id = :bc.getJobId()
        ];
        
        // Send email notification or trigger follow-up logic
        Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
        mail.setToAddresses(new String[] {'admin@example.com'});
        mail.setSubject('Opportunity Cleanup Complete');
        mail.setPlainTextBody(
            'Processed: ' + job.TotalJobItems + 
            ' | Errors: ' + job.NumberOfErrors
        );
        Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
    }
}

// Invoke with a specific batch size:
// Database.executeBatch(new OpportunityCleanupBatch(), 200);

Choosing your batch size:

The default and most commonly used batch size is 200. Go smaller (50-100) if your logic is SOQL-heavy or calls out within the batch. Go larger (up to 2000) only for simple DML operations with minimal query load.

Stateful batch — when you need to track data across chunks:

Apex Code

public class StatefulExample implements Database.Batchable<sObject>, Database.Stateful {
    public Integer totalProcessed = 0;
    
    public Database.QueryLocator start(Database.BatchableContext bc) { /* ... */ }
    
    public void execute(Database.BatchableContext bc, List<sObject> scope) {
        totalProcessed += scope.size();
        // process records...
    }
    
    public void finish(Database.BatchableContext bc) {
        System.debug('Total processed: ' + totalProcessed);
    }
}

Without Database.Stateful, class variables reset between batches. Add it only when you actually need to accumulate state — it has a performance cost.


Scheduled Apex Quick Guide

Apex Code

public class NightlyDataSyncScheduler implements Schedulable {
    
    public void execute(SchedulableContext sc) {
        // Most common pattern: kick off a batch job
        Database.executeBatch(new OpportunityCleanupBatch(), 200);
    }
}

Scheduling via code:

Apex Code

// Schedule to run every night at midnight
String cronExpression = '0 0 0 * * ?';
System.schedule('Nightly Data Sync', cronExpression, new NightlyDataSyncScheduler());

Salesforce cron expression format:

Seconds Minutes Hours Day-of-month Month Day-of-week Optional-year

Common patterns:

  • Every day at 2 AM: 0 0 2 * * ?
  • Every Monday at 8 AM: 0 0 8 ? * MON
  • Every hour: 0 0 * * * ?

Important limitations:

  • Maximum of 100 scheduled jobs per org at any time
  • You can’t schedule a scheduled job from within that same scheduled job (use a different approach to reschedule)
  • For more frequent than hourly execution, consider a different pattern (Queueable chaining or Platform Events)

Best Practices Across All Types

After working with async Apex across orgs of all sizes, these are the patterns that consistently save developers from painful debugging sessions.

1. Always handle errors explicitly

Async jobs fail silently if you don’t build in error handling. Log errors to a custom object, send emails from finish() in Batch, and use try-catch inside execute() methods.

2. Never assume context — always re-query

In future methods and Queueable jobs, pass IDs and re-query the full record inside the method. Data can change between when you enqueue the job and when it runs.

3. Avoid async from async unless you mean it

Calling a future method from within another async context usually fails or gets queued unexpectedly. Use System.isFuture(), System.isQueueable(), and System.isBatch() to check your execution context.

apex

if (!System.isFuture() && !System.isQueueable()) {
    MyFutureClass.doSomethingAsync(recordId);
}

4. Limit SOQL inside execute() in Batch

Even though each batch gets a fresh set of limits, it’s still easy to hit 200 SOQL queries per chunk. Move all queries to start() where possible, or use relationship queries to pull everything in one shot.

5. Use Database.Stateful sparingly

It’s tempting to track counters and state across batches, but Database.Stateful serializes your entire class between batches. On large jobs, this adds significant overhead. Use it only when you genuinely need cross-batch state.

6. Monitor jobs proactively

Query AsyncApexJob in your code or set up automated monitoring. Jobs can fail, stall, or get stuck in the queue — and you want to know before your users do.

apex

List<AsyncApexJob> failedJobs = [
    SELECT Id, Status, NumberOfErrors, ExtendedStatus
    FROM AsyncApexJob
    WHERE JobType = 'BatchApex' AND Status = 'Failed'
    AND CreatedDate = TODAY
];

7. Test with realistic data volumes

It’s easy to write async tests that pass with 5 records and then fail in production with 50,000. Use Test.startTest() / Test.stopTest() to properly invoke async execution in tests, and test with at least a few hundred records.


Testing Asynchronous Apex

Testing async code requires a slightly different mindset. Salesforce doesn’t actually run async jobs during tests — instead, it executes them synchronously when you call Test.stopTest(). This is actually pretty useful once you get used to it.

Testing Future Methods

Apex Code

@isTest
private class LeadIntegrationServiceTest {
    
    @isTest
    static void testSyncLeadToExternal() {
        Lead testLead = new Lead(
            FirstName = 'Test',
            LastName = 'Lead',
            Company = 'Test Corp',
            Email = 'test@example.com'
        );
        insert testLead;
        
        Test.setMock(HttpCalloutMock.class, new LeadCalloutMock());
        
        Test.startTest();
        LeadIntegrationService.syncLeadToExternalSystem(testLead.Id);
        Test.stopTest();
        
        // Assert expected outcomes here
    }
}

Testing Queueable Apex

Apex Code

@isTest
private class AccountEnrichmentJobTest {
    
    @isTest
    static void testEnrichmentJob() {
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < 10; i++) {
            accounts.add(new Account(Name = 'Test Account ' + i));
        }
        insert accounts;
        
        List<Id> accountIds = new List<Id>(new Map<Id, Account>(accounts).keySet());
        
        Test.startTest();
        System.enqueueJob(new AccountEnrichmentJob(accountIds));
        Test.stopTest();
        
        // Verify results
    }
}

Testing Batch Apex

Apex Code

@isTest
private class OpportunityCleanupBatchTest {
    
    @isTest
    static void testBatchCleanup() {
        // Create test data
        List<Opportunity> opps = new List<Opportunity>();
        for (Integer i = 0; i < 200; i++) {
            opps.add(new Opportunity(
                Name = 'Test Opp ' + i,
                StageName = 'Prospecting',
                CloseDate = Date.today().addDays(-30)
            ));
        }
        insert opps;
        
        Test.startTest();
        Database.executeBatch(new OpportunityCleanupBatch(), 200);
        Test.stopTest();
        
        // After Test.stopTest(), batch has run synchronously
        List<Opportunity> processed = [
            SELECT Id, StageName FROM Opportunity 
            WHERE Id IN :opps
        ];
        for (Opportunity opp : processed) {
            System.assertEquals('Closed Lost', opp.StageName);
        }
    }
}

Testing Scheduled Apex

Apex Code

@isTest
private class NightlySchedulerTest {
    
    @isTest
    static void testScheduledJob() {
        Test.startTest();
        String jobId = System.schedule(
            'Test Nightly Sync',
            '0 0 0 * * ?',
            new NightlyDataSyncScheduler()
        );
        Test.stopTest();
        
        CronTrigger ct = [
            SELECT Id, CronExpression, TimesTriggered
            FROM CronTrigger WHERE Id = :jobId
        ];
        System.assertEquals('0 0 0 * * ?', ct.CronExpression);
    }
}

Key testing rules:

  • Always use Test.startTest() and Test.stopTest() — async code won’t execute without them
  • Mock HTTP callouts with Test.setMock() — you can’t make real callouts in tests
  • Test your finish() method logic separately if it’s complex
  • Aim for at least 75% code coverage, but realistically target 90%+ for async classes

Conclusion + Decision Tree

Asynchronous Apex is one of the most important tools in a Salesforce developer’s toolkit — and one of the most misunderstood. Using the wrong async method doesn’t just mean inefficiency; it means hitting limits, creating fragile code, and building org debt you’ll pay for later.

Here’s a simple decision tree to wrap it all up:

Salesforce Apex Async Tree
Salesforce Apex Async Tree

The four async methods complement each other well. In real-world orgs, you’ll often use all four — Scheduled kicks off a Batch, a Batch finish() enqueues a Queueable, and a trigger fires a Future method for a quick callout. Understanding when and why to reach for each one is what separates clean Salesforce architecture from the kind that creates 2 AM emergencies.

Ready to put this into practice? Start by auditing your org’s existing future methods. Identify which ones would benefit from being refactored to Queueable — you’ll likely find a few immediately.


FAQ

What is asynchronous Apex in Salesforce?

Asynchronous Apex is code that runs outside the normal synchronous execution context — in the background, in a queue, or on a schedule. It includes Future Methods, Queueable Apex, Batch Apex, and Scheduled Apex. The main benefits are higher governor limits, better performance for users, and the ability to handle large data volumes or external callouts.

When should I use Queueable Apex instead of Future Methods?

Use Queueable Apex when you need to pass complex objects (sObjects, custom classes) as parameters, chain multiple async operations, or monitor job status via a job ID. Future methods are appropriate only for simple, stateless fire-and-forget tasks — particularly callouts from trigger context where Queueable’s extra overhead isn’t needed.

What is the maximum batch size in Batch Apex?

The maximum batch size in Batch Apex is 2,000 records per execute chunk. The default is 200. For most use cases, 200 is optimal because it balances query limits and DML limits per chunk. Use smaller batch sizes (50-100) when your logic is query-heavy, and larger sizes (up to 2,000) only for simple DML-only operations.

Can I call a Future method from a Batch Apex class?

No, you cannot call a future method from a Batch Apex class. Salesforce prevents calling future methods from async contexts in most scenarios. If you need to make callouts inside Batch Apex, implement the Database.AllowsCallouts interface directly on your batch class instead.

How many scheduled Apex jobs can I have in a Salesforce org?

A Salesforce org supports a maximum of 100 scheduled Apex jobs at any time. This includes both active and inactive scheduled jobs. If you need more frequent processing than scheduling allows, consider using Queueable Apex chaining or Platform Events to trigger processing more granularly.

How do I test async Apex in Salesforce?

Use Test.startTest() and Test.stopTest() to trigger async execution synchronously in test context. Salesforce runs all async jobs (future, queueable, batch, scheduled) synchronously when Test.stopTest() is called. For methods that make HTTP callouts, use Test.setMock() with an HttpCalloutMock implementation to simulate the external response.

What is the difference between Batch Apex and Queueable Apex?

Batch Apex is designed for processing large volumes of records (up to 50 million), with a structured three-phase lifecycle (start, execute, finish) where each chunk gets fresh governor limits. Queueable Apex is designed for flexible background processing with support for complex parameters and job chaining, but it processes all its work in a single execution context. Use Batch when you’re processing thousands of records; use Queueable for complex logic with manageable data sets.