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
- Why Asynchronous Processing?
- Types of Asynchronous Apex
- Comparison Table: All Async Methods
- Choosing the Right Async Method
- Future Methods Quick Guide
- Queueable Apex Quick Guide
- Batch Apex Quick Guide
- Scheduled Apex Quick Guide
- Best Practices Across All Types
- Testing Asynchronous Apex
- 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
staticandvoid - 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
Queueableinterface with anexecutemethod - 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.AllowsCalloutsfor 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.Batchableinterface - 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
Schedulableinterface - 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
| Feature | Future Method | Queueable Apex | Batch Apex | Scheduled Apex |
|---|---|---|---|---|
| Interface/Annotation | @future | Queueable | Database.Batchable | Schedulable |
| Complexity | Low | Medium | Medium-High | Low |
| Chaining | ❌ No | ✅ Yes (5 deep) | ❌ No | ❌ No |
| Complex Object Params | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes |
| Callouts | ✅ Yes | ✅ With interface | ✅ With interface | ❌ Indirectly |
| Monitoring | Limited | ✅ Job ID | ✅ Full | ✅ Full |
| Max Records | N/A | N/A | 50 million | N/A |
| Scheduling | ❌ No | ❌ No | ❌ No | ✅ Yes |
| Use Case | Simple callouts | Flexible async | Large data sets | Recurring jobs |
| SOQL Limit per transaction | 100 | 100 | 200 per batch | 100 |
| Heap Size | 12 MB | 12 MB | 12 MB per batch | 12 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:
| Limit | Future | Queueable | Batch (per execute) | Scheduled |
|---|---|---|---|---|
| SOQL queries | 100 | 100 | 200 | 100 |
| DML statements | 150 | 150 | 150 | 150 |
| CPU time | 60,000 ms | 60,000 ms | 60,000 ms | 60,000 ms |
| Heap size | 12 MB | 12 MB | 12 MB | 12 MB |
| Callouts | 100 | 100 | 100 | 0 (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:
- Do you need to run on a schedule? → Use Scheduled Apex (often to trigger a Batch)
- Are you processing 1,000+ records? → Use Batch Apex
- Do you need to chain steps or pass complex objects? → Use Queueable Apex
- 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 ansObjectdirectly. 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 — useSystem.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
sObjector 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()andTest.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:

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.