ESC

AI-powered search across all blog posts and tools

Apex · February 16, 2026

Queueable Apex - Advanced Patterns and Techniques

Beyond the basics — chaining, finalizers, error recovery, and real-world patterns for building robust async processing in Salesforce

☕ 9 min read 📅 February 16, 2026
  • Queueable Apex is the right choice when you need callouts, need to chain jobs sequentially, or need more than the standard 50,000 SOQL row limit per transaction
  • Transaction Finalizers (available since API 52.0) give you a reliable hook to detect job failure and implement retry logic
  • Chaining depth is limited to 1 level in test context — the 5-level cap applies to synchronous production context, while async context allows unlimited chaining

When I first encountered async Apex, I used @future methods for everything. They were simple, they worked, and I didn’t think too hard about the alternatives. Then I hit the limitations — no callout chaining, no state between executions, no way to know when something failed. Queueable Apex solved all of those problems, and once I understood its advanced patterns, it became the default for anything non-trivial.

In this article I’ll go beyond the basics and into the patterns that make production-grade async processing reliable.

Choosing Your Async Tool

Async Apex Comparison — @future vs Queueable vs Batch
Async Apex ComparisonFeature@futureQueueableBatch ApexHTTP Callouts✓ (AllowsCallouts)Job ChainingStateful (between chunks)Implements StatefulImplements StatefulMax Records50,000 (SOQL row limit)50,000 per jobUnlimitedTransaction FinalizerJob ID returned

The decision tree I follow: Use @future only for fire-and-forget callouts that don’t need to chain or report status. Use Batch Apex when you’re processing more records than fit in one Queueable execution. Use Queueable for everything else — it’s the most flexible option.

Bad: @future for Multi-Step

A developer uses three @future methods to authenticate, fetch data, then push data to an external system. Because @future methods can’t chain, the developer resorts to workarounds: storing intermediate state in custom objects between calls, adding delay loops, and hoping all three jobs run in order. The integration is fragile and non-deterministic.

Good: Queueable with Chaining

A single Queueable class handles the full flow using an enum-based step pattern. Each step makes one callout, processes the result, and enqueues the next step with the auth token passed through the constructor. The steps are guaranteed sequential, testable in isolation, and covered by a Transaction Finalizer for error recovery.

Queueable Fundamentals (The Right Way)

The bare minimum implementation most tutorials show is fine for learning but missing pieces you need in production:

public class RecordProcessingQueueable implements Queueable, Database.AllowsCallouts {

    private final List<Id> recordIds;
    private final Integer retryCount;

    // Pass data via constructor — Queueable serializes instance variables
    public RecordProcessingQueueable(List<Id> recordIds, Integer retryCount) {
        this.recordIds = recordIds;
        this.retryCount = retryCount;
    }

    public void execute(QueueableContext ctx) {
        // ctx.getJobId() gives you the current AsyncApexJob ID
        System.debug('Running job: ' + ctx.getJobId() + ' for ' + recordIds.size() + ' records');

        try {
            List<Account> accounts = [
                SELECT Id, Name, External_Id__c
                FROM Account
                WHERE Id IN :recordIds
            ];
            processAccounts(accounts);
        } catch (Exception e) {
            // Log, don't just swallow
            System.debug(LoggingLevel.ERROR, 'Job failed: ' + e.getMessage());
            throw e; // Re-throw so the finalizer knows it failed
        }
    }

    private void processAccounts(List<Account> accounts) {
        // Your business logic here
    }
}
🚨 Always Re-throw Caught Exceptions

A common mistake is catching exceptions in execute() for logging and then swallowing them with a bare catch block. If you do this, the Transaction Finalizer will see the job as successful even though it failed — because no exception propagated. Always re-throw after logging, or use the finalizer’s ctx.getException() to inspect the error after the job completes.

Chaining Queueables

One of Queueable’s most powerful features is the ability to enqueue a new job from within a running job. This creates a sequential processing chain without complex orchestration.

public class ChunkedSyncQueueable implements Queueable, Database.AllowsCallouts {

    private final List<Id> allIds;
    private final Integer batchSize;
    private final Integer offset;

    public ChunkedSyncQueueable(List<Id> allIds, Integer offset, Integer batchSize) {
        this.allIds = allIds;
        this.offset = offset;
        this.batchSize = batchSize;
    }

    public void execute(QueueableContext ctx) {
        // Get this chunk
        Integer end = Math.min(offset + batchSize, allIds.size());
        List<Id> chunk = new List<Id>();
        for (Integer i = offset; i < end; i++) {
            chunk.add(allIds[i]);
        }

        // Process this chunk
        syncToExternalSystem(chunk);

        // Enqueue next chunk if more remain
        Integer nextOffset = offset + batchSize;
        if (nextOffset < allIds.size() && !Test.isRunningTest()) {
            System.enqueueJob(new ChunkedSyncQueueable(allIds, nextOffset, batchSize));
        }
    }

    private void syncToExternalSystem(List<Id> ids) {
        // HTTP callout logic
    }
}
⚠️ Warning

The !Test.isRunningTest() guard is important — without it, your test will attempt to chain jobs inside a test context, which throws an error because Salesforce only allows one level of job enqueueing from within a test.

Transaction Finalizers — The Game Changer

Transaction Finalizers were introduced in API version 52.0 (Summer ‘21) and they solve a problem that made production Queueable deployments risky: you had no way to react when a job failed.

A finalizer is a separate class that runs after the Queueable job completes — whether it succeeded or failed. This is where you implement retry logic, send failure notifications, or clean up partial state.

public class RecordSyncFinalizer implements System.Finalizer {

    private final List<Id> recordIds;
    private final Integer retryCount;
    private static final Integer MAX_RETRIES = 3;

    public RecordSyncFinalizer(List<Id> recordIds, Integer retryCount) {
        this.recordIds = recordIds;
        this.retryCount = retryCount;
    }

    public void execute(System.FinalizerContext ctx) {
        ParentJobResult result = ctx.getResult();

        if (result == ParentJobResult.SUCCESS) {
            System.debug('Job completed successfully');
            return;
        }

        // Job failed
        Exception e = ctx.getException();
        System.debug('Job failed with: ' + e.getMessage());

        if (retryCount < MAX_RETRIES) {
            System.debug('Retrying... attempt ' + (retryCount + 1));
            System.enqueueJob(new RecordProcessingQueueable(recordIds, retryCount + 1));
        } else {
            sendFailureAlert(recordIds, e.getMessage());
        }
    }

    private void sendFailureAlert(List<Id> ids, String errorMessage) {
        Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
        mail.setToAddresses(new List<String>{ 'admin@yourorg.com' });
        mail.setSubject('Queueable Job Failed After ' + MAX_RETRIES + ' Retries');
        mail.setPlainTextBody('Records: ' + ids + '\\nError: ' + errorMessage);
        Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{ mail });
    }
}

You attach the finalizer in your Queueable’s execute method:

public void execute(QueueableContext ctx) {
    // Attach finalizer before any logic that could throw
    System.attachFinalizer(new RecordSyncFinalizer(recordIds, retryCount));

    // Your processing logic
    processRecords();
}
🚨 Attach the Finalizer Before Your First Throw-able Line

System.attachFinalizer() must be called before any code that might throw an exception. If your first line of business logic throws before the finalizer is attached, the finalizer never runs — you lose the safety net entirely. Make attaching the finalizer the absolute first statement in execute().

Callout Chains — Sequential External API Calls

One of the most common real-world patterns is a multi-step external API integration: authenticate, retrieve data, push data, confirm. Each step depends on the previous one’s result. Queueable chaining handles this elegantly.

public class OrderFulfillmentChain implements Queueable, Database.AllowsCallouts {

    public enum Step { AUTHENTICATE, FETCH_INVENTORY, SUBMIT_ORDER, CONFIRM }

    private final Id orderId;
    private final Step currentStep;
    private final String authToken;

    public OrderFulfillmentChain(Id orderId, Step step, String authToken) {
        this.orderId = orderId;
        this.currentStep = step;
        this.authToken = authToken;
    }

    public void execute(QueueableContext ctx) {
        System.attachFinalizer(new OrderFulfillmentFinalizer(orderId, currentStep));

        switch on currentStep {
            when AUTHENTICATE {
                String token = callAuthEndpoint();
                System.enqueueJob(new OrderFulfillmentChain(orderId, Step.FETCH_INVENTORY, token));
            }
            when FETCH_INVENTORY {
                Boolean available = checkInventory(authToken, orderId);
                if (available) {
                    System.enqueueJob(new OrderFulfillmentChain(orderId, Step.SUBMIT_ORDER, authToken));
                } else {
                    updateOrderStatus(orderId, 'Inventory_Unavailable');
                }
            }
            when SUBMIT_ORDER {
                String confirmationId = submitOrder(authToken, orderId);
                updateOrderWithConfirmation(orderId, confirmationId);
                System.enqueueJob(new OrderFulfillmentChain(orderId, Step.CONFIRM, authToken));
            }
            when CONFIRM {
                confirmDelivery(authToken, orderId);
                updateOrderStatus(orderId, 'Confirmed');
            }
        }
    }

    private String callAuthEndpoint() {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Fulfillment_API/auth');
        req.setMethod('POST');
        HttpResponse res = new Http().send(req);
        return (String) ((Map<String, Object>) JSON.deserializeUntyped(res.getBody())).get('token');
    }

    // ... other callout methods
}

Making Queueables Testable

One E2E Test (Bad)

A single test method enqueues the AUTHENTICATE step and expects the full chain (AUTHENTICATE -> FETCH_INVENTORY -> SUBMIT_ORDER -> CONFIRM) to complete. In test context, Salesforce only processes the first chain link. The test always fails at the second step and the developer spends hours debugging why the chain “doesn’t work” in tests.

Per-Step Unit Tests (Good)

Four test methods: one per step. Each test instantiates the Queueable directly at that step and verifies its specific output. The chain behavior is tested implicitly — each step enqueues the next, and the next step’s test verifies it handles valid input correctly. Coverage is complete and failures are pinpointed immediately.

ℹ️ Info

In test context, you can only chain one additional Queueable from a running Queueable. For multi-step chains, you need to test each step independently in separate test methods, not as a single end-to-end test.

The Stateful Queueable Pattern

Stateful Queueable with re-enqueue pattern

For Queueables that need to accumulate results across multiple executions, implement Database.Stateful:

public class AggregatingQueueable implements Queueable, Database.Stateful {

    private List<Id> remainingIds;
    private Integer processedCount = 0;
    private List<String> errors = new List<String>();

    public AggregatingQueueable(List<Id> ids) {
        this.remainingIds = ids;
    }

    public void execute(QueueableContext ctx) {
        System.attachFinalizer(new ReportingFinalizer(processedCount, errors));

        List<Id> chunk = dequeue(50);
        try {
            processChunk(chunk);
            processedCount += chunk.size();
        } catch (Exception e) {
            errors.add('Chunk failed: ' + e.getMessage());
        }

        if (!remainingIds.isEmpty()) {
            System.enqueueJob(this); // Re-enqueue this same instance
        }
    }

    private List<Id> dequeue(Integer size) {
        List<Id> chunk = new List<Id>();
        for (Integer i = 0; i < Math.min(size, remainingIds.size()); i++) {
            chunk.add(remainingIds.remove(0));
        }
        return chunk;
    }
}

Re-enqueueing this (the same instance) preserves the processedCount and errors state because Database.Stateful serializes instance variables between executions.

Governor Limits to Keep in Mind

  • Maximum queue depth: 250,000 jobs in the Flex Queue
  • Maximum jobs added from a single transaction: 50 (for Queueable)
  • Chaining limit in tests: 1 level deep from within Test.startTest()/stopTest() (the 5-level cap applies to synchronous production context only)
  • Finalizer memory limit: 4 MB heap (separate from the parent job)
  • A finalizer can enqueue exactly 1 new Queueable job
💡 Monitor AsyncApexJob for Queue Health

Query SELECT Id, Status, NumberOfErrors, JobType FROM AsyncApexJob WHERE JobType = 'Queueable' ORDER BY CreatedDate DESC LIMIT 50 in Developer Console or SOQL Explorer to spot jobs stuck in Queued or Holding status. A large backlog here means the flex queue is saturated — something that rarely surfaces in logs until users start complaining about delayed processing.

When to Use Batch Instead

Despite Queueable’s flexibility, Batch Apex is still the right choice when you need to process more records than comfortably fit in a chunked Queueable chain. The overhead of managing chunk sizes and chain depth manually in Queueable is not worth it once you’re above 100,000 records. Batch Apex was built for that scale.

The hybrid pattern I often use: Batch Apex for the heavy data processing, Queueable for the callout-based notification or downstream sync triggered by each batch’s finish() method.


What async patterns are you using in your org? Are teams adopting transaction finalizers, or is that still underused in your experience? I’d especially like to hear from anyone who has built retry logic in production — drop your approach in the comments.


Why should you attach a Transaction Finalizer as the very first line in your Queueable's execute() method?
How many levels of Queueable chaining are allowed in a test context?

How did this article make you feel?

Comments

Salesforce Tip

🎉

You finished this article!

What to read next

Contents