LearnFrenzy
Sign in Take a free test

Asynchronous Apex · Lesson 30 of 41

Queueable Apex: Chaining, Finalizers and Monitoring

6 min read Updated Nov 8, 2026 Saurabh Samir

Queueable Apex is the workhorse of background processing. It runs later in its own transaction like @future, but it's a real class: you can pass it any data, get a job Id to monitor, chain one job after another, and attach a Finalizer that runs even if the job fails. Most new async code in Salesforce should be a Queueable.

In this lesson

  • Implementing and enqueueing a Queueable
  • Monitoring with AsyncApexJob
  • Chaining jobs
  • Finalizers for failure handling
  • Callouts and testing

A Queueable class

// CaseEscalationJob.cls
public with sharing class CaseEscalationJob implements Queueable {
    private final List<Case> cases;                     // any type: sObjects, wrappers, maps…
    private final String reason;

    public CaseEscalationJob(List<Case> cases, String reason) {
        this.cases = cases;
        this.reason = reason;
    }

    public void execute(QueueableContext context) {
        List<Case> updates = new List<Case>();
        for (Case c : [SELECT Id, Priority FROM Case WHERE Id IN :cases AND IsClosed = false]) {
            updates.add(new Case(Id = c.Id, Priority = 'High', Description = 'Escalated: ' + reason));
        }
        update updates;
    }
}
// enqueue it – from a trigger handler, a controller, anywhere
Id jobId = System.enqueueJob(new CaseEscalationJob(cases, 'SLA breached'));

// check on it later
AsyncApexJob job = [SELECT Status, NumberOfErrors, ExtendedStatus FROM AsyncApexJob WHERE Id = :jobId];

enqueueJob returns the job's Id straight away. AsyncApexJob tells you its status: Queued, Preparing, Processing, Completed, Failed, Aborted. Setup → Apex Jobs shows the same list.

Testing it

// CaseEscalationJobTest.cls
@IsTest
private class CaseEscalationJobTest {
    @IsTest
    static void escalatesOnlyOpenCases() {
        List<Case> cases = new List<Case>{
            new Case(Subject = 'Open one', Priority = 'Low', Status = 'New'),
            new Case(Subject = 'Closed one', Priority = 'Low', Status = 'Closed')
        };
        insert cases;

        Test.startTest();
        Id jobId = System.enqueueJob(new CaseEscalationJob(cases, 'SLA breached'));
        Test.stopTest();                                        // the job runs here

        Assert.areEqual('Completed', [SELECT Status FROM AsyncApexJob WHERE Id = :jobId].Status);
        Assert.areEqual('High', [SELECT Priority FROM Case WHERE Subject = 'Open one'].Priority);
        Assert.areEqual('Low', [SELECT Priority FROM Case WHERE Subject = 'Closed one'].Priority);
    }
}

Test run

✓ CaseEscalationJobTest.escalatesOnlyOpenCases

Tests: 1 passed, 0 failed, 1 total

Chaining jobs

A Queueable can enqueue one more Queueable from its execute method. That's how you process work in steps, each with fresh limits – page through records, or call an API page by page:

// ContactCleanupJob.cls
public with sharing class ContactCleanupJob implements Queueable {
    private final Integer batchSize;
    private final Integer round;

    public ContactCleanupJob(Integer batchSize, Integer round) {
        this.batchSize = batchSize;
        this.round = round;
    }

    public void execute(QueueableContext context) {
        List<Contact> dirty = [SELECT Id, Email FROM Contact WHERE Department = 'needs cleanup' LIMIT :batchSize];
        for (Contact c : dirty) {
            c.Email = c.Email?.trim().toLowerCase();
            c.Department = 'cleaned in round ' + round;
        }
        update dirty;

        Boolean moreWork = [SELECT COUNT() FROM Contact WHERE Department = 'needs cleanup'] > 0;
        if (moreWork && !Test.isRunningTest()) {                 // tests can't run a chain
            System.enqueueJob(new ContactCleanupJob(batchSize, round + 1));
        }
    }
}
  • Only one child job can be enqueued from an executing Queueable.
  • Always have a stop condition – an endless chain keeps consuming your org's daily async executions.
  • In tests, a chained job can't run after stopTest(); guard the enqueue with Test.isRunningTest() (as here) or set a maximum stack depth with AsyncOptions, and test each step directly.

Finalizers: handle failures

If a Queueable throws an uncaught exception, its work rolls back and nothing happens unless someone checks Apex Jobs. A Finalizer runs after the job finishes – successfully or not – in its own transaction, so it can log the failure or retry:

// ImportJob.cls
public with sharing class ImportJob implements Queueable {
    private final List<String> names;

    public ImportJob(List<String> names) { this.names = names; }

    public void execute(QueueableContext context) {
        System.attachFinalizer(new ImportFinalizer(names.size()));
        List<Account> accounts = new List<Account>();
        for (String n : names) accounts.add(new Account(Name = n));
        insert accounts;                                         // fails if a name is blank
    }
}
// ImportFinalizer.cls
public with sharing class ImportFinalizer implements Finalizer {
    private final Integer expected;

    public ImportFinalizer(Integer expected) { this.expected = expected; }

    public void execute(FinalizerContext ctx) {
        if (ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
            // the job's own DML was rolled back; record what happened in a new transaction
            insert new Task(Subject = 'Import failed: ' + ctx.getException().getTypeName(),
                            Description = ctx.getException().getMessage().left(200));
        }
    }
}
// ImportJobTest.cls
@IsTest
private class ImportJobTest {
    @IsTest
    static void successfulImport() {
        Test.startTest();
        System.enqueueJob(new ImportJob(new List<String>{ 'Acme', 'Globex' }));
        Test.stopTest();
        Assert.areEqual(2, [SELECT COUNT() FROM Account]);
        Assert.areEqual(0, [SELECT COUNT() FROM Task]);
    }

    @IsTest
    static void failureIsRecordedByTheFinalizer() {
        try {
            Test.startTest();
            System.enqueueJob(new ImportJob(new List<String>{ 'Acme', '' }));
            Test.stopTest();
        } catch (DmlException e) {
            // in tests the job's exception surfaces at stopTest; in production it doesn't
        }
        Assert.areEqual(0, [SELECT COUNT() FROM Account], 'the job rolled back');
        Task t = [SELECT Subject FROM Task LIMIT 1];
        Assert.areEqual('Import failed: System.DmlException', t.Subject);
    }
}

Test run

✓ ImportJobTest.failureIsRecordedByTheFinalizer
✓ ImportJobTest.successfulImport

Tests: 2 passed, 0 failed, 2 total

Callouts and other options

  • Implement Database.AllowsCallouts as well to make HTTP callouts: public class SyncJob implements Queueable, Database.AllowsCallouts.
  • System.enqueueJob(job, delayInMinutes) delays the start (0–10 minutes) – handy for retries with back-off.
  • AsyncOptions lets you set a duplicate signature (to avoid enqueueing the same job twice) and a maximum stack depth for chains.
  • Limit: 50 jobs can be enqueued per synchronous transaction; one per executing Queueable.

Practice

Try it yourself

  1. Convert the ErpSync future method from the previous lesson into a Queueable that also retries once after 5 minutes if the API returns 503.

    Show answer

    public class ErpSyncJob implements Queueable, Database.AllowsCallouts { Set<Id> ids; Integer attempt; … execute: send the request; if (res.getStatusCode() == 503 && attempt < 2) System.enqueueJob(new ErpSyncJob(ids, attempt + 1), 5); }

  2. Your Queueable fails silently in production now and then. What do you add?

    Show answer

    A Finalizer that checks ctx.getResult() and logs the exception (or re-enqueues the job) – plus monitoring of AsyncApexJob for Failed jobs.

Quick check

What does System.enqueueJob return?

The job Id (an AsyncApexJob Id).

How many jobs can an executing Queueable enqueue?

One.

When does a Finalizer run?

After its Queueable finishes – whether it succeeded or threw an exception.

Summary

  • Queueables accept any state, return a job Id and can be monitored.
  • Chain one job at a time, with a clear stop condition.
  • Attach a Finalizer to log or retry failures.
  • Add Database.AllowsCallouts for callouts; test with startTest/stopTest.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links