Table of Contents
1 What is Queueable Apex?
Queueable Apex is Salesforce's most flexible asynchronous execution mechanism. Introduced in API version 31.0, it improves upon the older @future annotation by giving you a proper class-based interface, the ability to pass complex objects as state, native job chaining, and a monitorable job ID returned by System.enqueueJob().
When you enqueue a Queueable job, Salesforce places it in the Apex job queue. The job runs asynchronously in a separate execution context with its own governor limit allocation — meaning it does not count against the limits of the transaction that enqueued it. This makes Queueable ideal for post-processing work, external API integration, and multi-step pipelines that would otherwise exceed synchronous limits.
When to Use Queueable Apex
- Processing large numbers of records in a non-batch pattern (a few hundred at a time).
- Making HTTP callouts after a DML transaction completes.
- Implementing a multi-step pipeline where step N triggers step N+1.
- Any async work where you need to pass an SObject, a List, or a Map as input — not just primitives.
- When you need to monitor or abort the job by its ID.
@future methods (simplest, fewest features), Queueable Apex (class-based, chainable, supports complex types), and Batch Apex (designed for millions of records, split into chunks automatically). This tutorial focuses exclusively on Queueable.Key Benefits at a Glance
2 The Queueable Interface & execute()
To create a Queueable Apex class, implement the Queueable interface and define the single required method: execute(QueueableContext context). The QueueableContext parameter provides access to the current job's ID via context.getJobId().
public class AccountDescriptionUpdater implements Queueable {
// Member fields hold the job's state — can be any serializable type
private List<Id> accountIds;
private String newDescription;
// Constructor accepts complex state
public AccountDescriptionUpdater(List<Id> ids, String desc) {
this.accountIds = ids;
this.newDescription = desc;
}
// The single method required by the Queueable interface
public void execute(QueueableContext ctx) {
// ctx.getJobId() returns the AsyncApexJob ID for this job
System.debug('Running job: ' + ctx.getJobId());
List<Account> accs = [
SELECT Id, Description
FROM Account
WHERE Id IN :accountIds
];
for (Account a : accs) {
a.Description = newDescription;
}
update accs;
}
}
QueueableContext Interface
The QueueableContext interface exposes one method:
| Method | Return Type | Description |
|---|---|---|
getJobId() |
ID |
Returns the AsyncApexJob record ID for the currently executing job. Use it to link child jobs or log progress. |
3 Enqueuing a Job with System.enqueueJob()
System.enqueueJob(queueable) submits a Queueable instance to the platform job queue and immediately returns an ID — the AsyncApexJob record ID. Salesforce picks up and executes the job asynchronously, usually within seconds in a live org.
// Collect record IDs after a trigger fires
List<Id> ids = new List<Id>();
for (Account a : Trigger.new) {
ids.add(a.Id);
}
// Instantiate and enqueue — returns AsyncApexJob ID
Id jobId = System.enqueueJob(new AccountDescriptionUpdater(ids, 'Updated async'));
System.debug('Enqueued job: ' + jobId);
Monitoring the Job
Because enqueueJob() returns an ID, you can query the AsyncApexJob object to check status programmatically or navigate to Setup > Apex Jobs in the UI.
Id jobId = System.enqueueJob(new AccountDescriptionUpdater(ids, 'Processed'));
// Query the job record — useful in tests or monitoring flows
AsyncApexJob job = [
SELECT Id, Status, NumberOfErrors, ExtendedStatus
FROM AsyncApexJob
WHERE Id = :jobId
LIMIT 1
];
System.debug('Status: ' + job.Status);
Possible Status Values
| Status | Meaning |
|---|---|
| Queued | Job is waiting in the queue and has not started yet. |
| Preparing | Salesforce is setting up the execution context. |
| Processing | The execute() method is actively running. |
| Completed | The job finished without unhandled exceptions. |
| Failed | An unhandled exception occurred. Check ExtendedStatus. |
| Aborted | The job was stopped by System.abortJob(jobId). |
Aborting a Queued Job
You can abort a job that has not yet started processing using System.abortJob(jobId). Once the status is Processing, the job cannot be aborted.
Id jobId = System.enqueueJob(new AccountDescriptionUpdater(ids, 'to be aborted'));
// Only works while status is still Queued
System.abortJob(jobId);
System.enqueueJob() more than 50 times in one transaction a LimitException is thrown. Use Limits.getQueueableJobs() and Limits.getLimitQueueableJobs() to check remaining headroom before enqueuing.4 Passing State & SObjects Between Jobs
One of the primary advantages of Queueable Apex over @future methods is the ability to store any serializable type as a member variable — including SObjects, custom Apex objects, and generic collections. This eliminates the need to re-query data inside the job when you already have it in memory.
public class ContactSyncJob implements Queueable {
private List<Contact> contacts; // SObject list — allowed as member field
private Map<Id, String> accountNameMap; // Map with SObject ID keys — also fine
public ContactSyncJob(List<Contact> contacts, Map<Id, String> nameMap) {
this.contacts = contacts;
this.accountNameMap = nameMap;
}
public void execute(QueueableContext ctx) {
List<Contact> toUpdate = new List<Contact>();
for (Contact c : contacts) {
String parentName = accountNameMap.get(c.AccountId);
if (parentName != null) {
c.Description = 'Parent: ' + parentName;
toUpdate.add(c);
}
}
if (!toUpdate.isEmpty()) {
update toUpdate;
}
}
}
Passing Custom Apex Objects
You can also pass instances of your own Apex classes as long as those classes are serializable (no interface fields, no static references, no HTTP/Database objects).
public class SyncConfig {
public String endpoint;
public Integer batchSize;
public Boolean dryRun;
public SyncConfig(String ep, Integer bs, Boolean dr) {
endpoint = ep;
batchSize = bs;
dryRun = dr;
}
}
public class ExternalSyncJob implements Queueable, Database.AllowsCallouts {
private SyncConfig config;
private List<Id> recordIds;
public ExternalSyncJob(SyncConfig cfg, List<Id> ids) {
this.config = cfg;
this.recordIds = ids;
}
public void execute(QueueableContext ctx) {
if (config.dryRun) {
System.debug('Dry run — skipping callout');
return;
}
// ... perform real callout using config.endpoint
}
}
5 Making HTTP Callouts from Queueable Apex
To make HTTP callouts inside a Queueable job, your class must also implement the Database.AllowsCallouts marker interface. Without it, any attempt to execute Http.send() will throw a CalloutException at runtime.
execute() method, avoid mixing DML before callouts — do callouts first, then DML, or split them across two chained jobs.public class SlackNotifierJob implements Queueable, Database.AllowsCallouts {
private String webhookUrl;
private String message;
public SlackNotifierJob(String url, String msg) {
this.webhookUrl = url;
this.message = msg;
}
public void execute(QueueableContext ctx) {
HttpRequest req = new HttpRequest();
req.setEndpoint(webhookUrl);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody('{"text":"' + message + '"}');
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() != 200) {
System.debug('Slack callout failed: ' + res.getStatus());
}
}
}
Testing Queueable Callouts
In test classes, set up a mock before calling Test.startTest() so the platform intercepts the HTTP request. Then enqueue the job inside Test.startTest() / Test.stopTest() to ensure it runs synchronously within the test.
// 1. Define the mock
public class SlackMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody('ok');
return res;
}
}
// 2. Use in test
@isTest
private class SlackNotifierJobTest {
@isTest
static void testCallout() {
Test.setMock(HttpCalloutMock.class, new SlackMock());
Test.startTest();
Id jobId = System.enqueueJob(new SlackNotifierJob(
'https://hooks.slack.com/xxx', 'Hello from test'
));
Test.stopTest();
AsyncApexJob job = [
SELECT Status FROM AsyncApexJob WHERE Id = :jobId
];
System.assertEquals('Completed', job.Status);
}
}
System.enqueueJob() inside Test.startTest() / Test.stopTest(). The platform executes all queued jobs synchronously when Test.stopTest() is called, ensuring your assertions run after the job completes.6 Job Chaining
Job chaining is one of the most powerful features unique to Queueable Apex. Inside execute(), you can call System.enqueueJob() with a new Queueable instance to schedule the next step in a pipeline. The new job will execute after the current job completes.
This pattern enables multi-step asynchronous workflows without Batch Apex's overhead, and without complex Schedulable scheduling. Each step runs with fresh governor limits.
// Step 1: Fetch and normalise data
public class Step1_FetchJob implements Queueable, Database.AllowsCallouts {
public void execute(QueueableContext ctx) {
// Perform external API callout
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:My_Named_Credential/api/data');
req.setMethod('GET');
HttpResponse res = new Http().send(req);
// Parse response into a list of IDs to process
List<String> externalIds = MyParser.parseIds(res.getBody());
// Chain to Step 2, passing parsed data
System.enqueueJob(new Step2_ProcessJob(externalIds));
}
}
// Step 2: Match external IDs to Salesforce records and update
public class Step2_ProcessJob implements Queueable {
private List<String> externalIds;
public Step2_ProcessJob(List<String> ids) {
this.externalIds = ids;
}
public void execute(QueueableContext ctx) {
List<Account> accs = [
SELECT Id, External_Id__c
FROM Account
WHERE External_Id__c IN :externalIds
];
for (Account a : accs) {
a.Description = 'Synced';
}
update accs;
// Chain to Step 3 for notification
System.enqueueJob(new Step3_NotifyJob(accs.size()));
}
}
// Step 3: Send a summary notification
public class Step3_NotifyJob implements Queueable {
private Integer processedCount;
public Step3_NotifyJob(Integer count) {
this.processedCount = count;
}
public void execute(QueueableContext ctx) {
// Insert a log record
insert new Sync_Log__c(
Records_Processed__c = processedCount,
Completed_At__c = DateTime.now()
);
}
}
System.enqueueJob() from inside a running Queueable job's execute() method is allowed for only one level. If your chain is deeper than one hop, the inner enqueue calls are silently ignored during tests. Write separate test methods to cover each link in the chain individually.Guarding Against Infinite Chains
Recursive or unbounded chains can exhaust the org's queue. Always include a termination condition — a record count, a flag field on a custom setting, or a maximum iteration counter stored in the job's state.
public class PaginatedSyncJob implements Queueable, Database.AllowsCallouts {
private Integer page;
private static final Integer MAX_PAGES = 10;
public PaginatedSyncJob(Integer page) {
this.page = page;
}
public void execute(QueueableContext ctx) {
// Call external API page
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:My_API/items?page=' + page);
req.setMethod('GET');
HttpResponse res = new Http().send(req);
Boolean hasMore = MyParser.hasNextPage(res.getBody());
// Only chain if more pages exist AND we have not hit our safety limit
if (hasMore && page < MAX_PAGES) {
System.enqueueJob(new PaginatedSyncJob(page + 1));
}
}
}
7 The Finalizer Interface
Introduced in API version 52.0, the Finalizer interface lets you attach post-execution logic to a Queueable job — logic that runs regardless of whether the job succeeded or failed with an unhandled exception. This is the Queueable equivalent of a finally block.
You attach a Finalizer inside execute() by calling System.attachFinalizer(myFinalizer). After the Queueable job completes (or fails), the platform runs the Finalizer's execute(FinalizerContext ctx) method in a separate transaction with its own governor limits.
// The Finalizer class
public class SyncJobFinalizer implements Finalizer {
public void execute(FinalizerContext ctx) {
String jobId = ctx.getAsyncApexJobId();
ParentJobResult result = ctx.getResult();
if (result == ParentJobResult.UNHANDLED_EXCEPTION) {
Exception ex = ctx.getException();
// Insert an error log record
insert new Error_Log__c(
Job_Id__c = jobId,
Message__c = ex.getMessage(),
Stack__c = ex.getStackTraceString()
);
// Optionally re-enqueue a recovery job
System.enqueueJob(new SyncRecoveryJob(jobId));
} else {
System.debug('Job ' + jobId + ' completed successfully');
}
}
}
// Attach the Finalizer inside execute()
public class DataSyncJob implements Queueable {
public void execute(QueueableContext ctx) {
// Attach before any risky operations
System.attachFinalizer(new SyncJobFinalizer());
// ... main job logic ...
List<Account> accs = [SELECT Id FROM Account LIMIT 200];
update accs;
}
}
FinalizerContext Methods
| Method | Return Type | Description |
|---|---|---|
getAsyncApexJobId() |
ID |
The AsyncApexJob ID of the parent Queueable job that triggered this Finalizer. |
getResult() |
ParentJobResult |
Either SUCCESS or UNHANDLED_EXCEPTION. |
getException() |
Exception |
The unhandled exception that caused the parent job to fail. null if the job succeeded. |
System.enqueueJob() from within a Finalizer (one enqueue allowed), making it ideal for retry or recovery patterns. Only one Finalizer can be attached per Queueable job — calling System.attachFinalizer() more than once throws an exception.8 Queueable vs Future vs Batch Apex — Comparison
Salesforce offers three primary async execution models. Choosing the right one depends on the nature of your workload, the types of data you need to pass, and whether you need monitoring or chaining. The table below compares all three across the most important dimensions.
| Feature | @future | Queueable | Batch Apex |
|---|---|---|---|
| How to define | @future annotation on a static method |
Implement Queueable interface |
Implement Database.Batchable interface |
| How to invoke | MyClass.myFutureMethod() |
System.enqueueJob(new MyJob()) |
Database.executeBatch(new MyBatch()) |
| Returns job ID? | No | Yes — AsyncApexJob ID |
Yes — AsyncApexJob ID |
| Parameter types | Primitives and collections of primitives only | Any serializable type (SObjects, custom classes, Maps) | Defined by start() return type — Database.QueryLocator or Iterable |
| HTTP callouts | Yes — add callout=true in annotation |
Yes — implement Database.AllowsCallouts |
Yes — implement Database.AllowsCallouts on the class |
| Job chaining | Not supported | Yes — call System.enqueueJob() inside execute() |
Yes — call Database.executeBatch() inside finish() |
| Max records recommended | Small sets (use SOQL inside method) | Up to ~50,000 depending on complexity | Millions — platform auto-chunks into configurable batch sizes |
| Post-execution hook | None | Finalizer interface (API 52.0+) | finish(Database.BatchableContext) method |
| Max concurrent jobs | 50 enqueued per transaction | 50 enqueued per transaction | 5 concurrent batch jobs per org |
| Best for | Simple one-off async, primitive-only callouts | Complex state, callouts, pipelines, monitoring | Bulk data migrations, nightly processing of millions of records |
Governor Limits in Async Context
All asynchronous Apex — Queueable, Batch execute(), and Schedulable — runs with the same higher async governor limits compared to synchronous code. Below are the key limits applicable to Queueable jobs:
| Limit | Sync value | Async (Queueable) value |
|---|---|---|
| Total SOQL queries | 100 | 200 |
| Total DML statements | 150 | 150 |
| Total DML rows | 10,000 | 10,000 |
| Total SOQL rows retrieved | 50,000 | 50,000 |
| Heap size | 6 MB | 12 MB |
| CPU time | 10,000 ms | 60,000 ms |
| Callouts per transaction | 100 | 100 |
| Future calls per transaction | 50 | 0 (cannot call @future from async) |
@future only for the simplest fire-and-forget scenarios where you do not need state, chaining, or monitoring.Practice Queueable Apex on ApexArena
Reinforce what you have learned by solving hands-on Queueable Apex problems in ApexArena's live Salesforce sandbox environment. No setup required.
Write Your First Queueable Job
Implement the Queueable interface, accept SObject state, and update records asynchronously.
Start solving →Chain Two Queueable Jobs
Build a two-step pipeline where job one fetches data and job two processes it.
Start solving →Queueable HTTP Callout
Implement Database.AllowsCallouts, make an HTTP POST, and parse the response in execute().
Start solving →Error Recovery with Finalizer
Attach a Finalizer to a failing Queueable job, log the exception, and re-enqueue a recovery job.
Start solving →Frequently Asked Questions
@future methods — Queueable jobs can hold state in class fields (including non-primitive types like SObjects and custom objects), can be chained (one job enqueuing the next), can make HTTP callouts when combined with Database.AllowsCallouts, and return a job ID (AsyncApexJob ID) that you can monitor.@future methods are the simplest async mechanism but are limited: they only accept primitive parameters (no SObjects), cannot be chained, and provide no job ID. Queueable Apex fixes all of these: it accepts any serializable member variable, supports chaining via System.enqueueJob() inside execute(), and returns an ID. Use @future only for simple fire-and-forget callouts; use Queueable for anything that needs state, chaining, or monitoring.execute(QueueableContext context) method of your Queueable class, call System.enqueueJob(new MyNextQueueableJob(someData)). This enqueues a new job that will run after the current job completes. In production, you can chain up to a depth of the queue depth limit. In test context only one level of chaining is allowed — deeper chains are silently skipped, so design tests to verify one level at a time.Database.AllowsCallouts in addition to Queueable: public class MyJob implements Queueable, Database.AllowsCallouts. With this interface marker, the execute() method can perform HTTP callouts using HttpRequest/HttpResponse. In tests, use Test.setMock(HttpCalloutMock.class, new MyMock()) before calling Test.startTest() to intercept callouts.System.enqueueJob() returns an ID of type ID (the AsyncApexJob record ID). You can query AsyncApexJob to check its Status field: Queued, Processing, Completed, Failed, or Aborted. In Setup > Apex Jobs you can see all running and completed jobs. You can also abort a queued job using System.abortJob(jobId) as long as it has not yet started processing.More Salesforce Tutorials
Apex Triggers — Complete Guide
Events, context variables, bulkification, handler patterns, recursive guards.
Read tutorial →SOQL & SOSL Tutorial
Queries, relationships, aggregate functions, semi-joins, and best practices.
Read tutorial →Batch Apex Tutorial
Database.Batchable, start/execute/finish, chaining, and scheduling batches.
Read tutorial →Salesforce Apex Interview Questions
50+ frequently asked Salesforce developer interview questions and answers.
Read guide →