Queueable Apex Tutorial

Salesforce Queueable Apex —
Complete Tutorial & Examples

Everything you need to write and chain Queueable Apex jobs: the Queueable interface, job IDs, callouts, job chaining, Finalizer interface, and when to use Queueable vs Future vs Batch.

🕐 ~15 min read 💻 Async Apex 👥 All experience levels
Advertisement

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.
ℹ️
New to async Apex? Salesforce has three primary async mechanisms: @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

50
Max jobs enqueued per transaction (synchronous context)
1
Level of chaining allowed per job in test context
ID
Job ID returned by System.enqueueJob() for monitoring
Any
Parameter types accepted — SObjects, Lists, Maps, custom classes
Yes
HTTP callouts supported via Database.AllowsCallouts
Yes
Post-execution error handling via Finalizer interface

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().

Apex — Minimal Queueable ClassPattern
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.
ℹ️
Serialization requirement: All member variables of a Queueable class must be serializable. Primitive types, SObjects, collections of primitives or SObjects, and most custom Apex classes are serializable. Interfaces, HTTP objects, and Database cursor types are not — do not store them as member fields.

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.

Apex — Enqueuing from a Trigger or ClassGood
// 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.

Apex — Querying Job Status
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
QueuedJob is waiting in the queue and has not started yet.
PreparingSalesforce is setting up the execution context.
ProcessingThe execute() method is actively running.
CompletedThe job finished without unhandled exceptions.
FailedAn unhandled exception occurred. Check ExtendedStatus.
AbortedThe 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.

Apex — Aborting a Job
Id jobId = System.enqueueJob(new AccountDescriptionUpdater(ids, 'to be aborted'));

// Only works while status is still Queued
System.abortJob(jobId);
⚠️
Governor limit: A single synchronous transaction can enqueue at most 50 Queueable jobs. If you call 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.

Apex — Passing SObject List as StateGood
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;
        }
    }
}
⚠️
Heap size warning: Queueable jobs run with a heap limit of 12 MB (synchronous context when triggered from async) or 6 MB in some contexts. Storing large SObject lists in member fields consumes heap. If you need to process millions of records, use Batch Apex instead and let Salesforce manage chunking automatically.

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).

Apex — Custom State Object
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.

🛑
DML before callout restriction: Salesforce enforces that you cannot perform a callout after DML in the same execution context unless that DML happened in a prior transaction. Inside a Queueable job's execute() method, avoid mixing DML before callouts — do callouts first, then DML, or split them across two chained jobs.
Apex — Queueable with HTTP CalloutPattern
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.

Apex — HttpCalloutMock for Queueable Tests
// 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);
    }
}
Best practice: Always wrap 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.

Apex — Three-Step Chained PipelinePattern
// 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()
        );
    }
}
⚠️
Test context chaining limit: In test classes, calling 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.

Apex — Bounded Recursive ChainGood
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.

Apex — Implementing a Finalizer
// 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.
ℹ️
Finalizer limits: The Finalizer runs in its own transaction with standard async governor limits. You can call 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)
Rule of thumb: If you are processing < 50,000 records with complex logic or external callouts, Queueable is usually the right choice. For nightly bulk data loads or migrations of hundreds of thousands of records, use Batch Apex. Reserve @future only for the simplest fire-and-forget scenarios where you do not need state, chaining, or monitoring.
Advertisement

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.

Frequently Asked Questions

What is Queueable Apex in Salesforce?
Queueable Apex is an asynchronous execution framework that lets you run Apex code in a background queue. It is an upgrade over @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.
What is the difference between Queueable Apex and Future methods?
@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.
How do you chain Queueable Apex jobs?
Inside the 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.
Can Queueable Apex make HTTP callouts?
Yes — you must implement 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.
How do you monitor a Queueable Apex job?
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