---
title: "Queueable chaining and its limits"
url: "https://trailassembly.com/queueable-chaining-limits/"
topic: "Apex and Development"
published: "2026-09-16T06:44:43+00:00"
updated: "2026-10-06T13:56:24+00:00"
---

# Queueable chaining and its limits

**The short answer:** a [queueable](https://trailassembly.com/apex-choosing-async/) job can enqueue exactly one child job. That single rule is what makes chaining work and what stops it becoming a fork bomb. There is no published depth limit in production, but the chain is invisible unless you give each link an identifier, and an unmonitored chain that stops halfway looks identical to one that never started.

## The rule in one line

From inside `execute()`, you get one `System.enqueueJob()` call. Attempt a second and you get a limit exception. That is the mechanism: each link starts the next, and only the next.

```apex
public class ChunkProcessor implements Queueable {
    private List<Id> remaining;

    public ChunkProcessor(List<Id> remaining) {
        this.remaining = remaining;
    }

    public void execute(QueueableContext ctx) {
        List<Id> batch = new List<Id>();
        while (!remaining.isEmpty() && batch.size() < 50) {
            batch.add(remaining.remove(0));
        }

        doWork(batch);

        if (!remaining.isEmpty() && !Test.isRunningTest()) {
            System.enqueueJob(new ChunkProcessor(remaining));
        }
    }
}
```

The `Test.isRunningTest()` guard is there because a test cannot chain. Enqueueing from a queueable in a test context throws, so the chain has to be suppressed and the recursion tested separately by invoking the next link directly.

## What you get per link

Each link is a fresh asynchronous transaction with a full set of limits: 100 queries, 50,000 query rows, 150 DML statements, 10,000 DML rows, 60,000ms CPU and 12MB heap. That reset is the reason chaining exists. A job too heavy for one transaction becomes several transactions, each with its own budget.

The 50 queueable jobs per transaction limit applies to the entry point, not to the chain. Synchronous code can enqueue up to 50 jobs. A queueable can enqueue one.

## Depth, and what actually stops a chain

Salesforce documents a depth limit in Developer Edition and trial orgs (5 links), and no hard depth limit in production and sandbox orgs. In practice chains of thousands of links run fine.

What stops them is less tidy:

- **An unhandled exception.** The link fails, the next is never enqueued, and the chain ends silently. This is the common failure.

- **Async [capacity](https://trailassembly.com/salesforce-capacity-planning/).** Orgs have a 24 hour rolling limit on asynchronous Apex executions, and a very long chain contributes to it alongside everything else.

- **Someone aborting the job** in Apex Jobs, which stops the running link and therefore the chain.

Design for the exception. Wrap the work in a try/catch, log the failure, and enqueue the next link anyway if the remaining work is still valid. A chain that skips a bad chunk and carries on beats one that dies on record 400 of 90,000.

## The flex queue

Queueable jobs share the flex queue with batch jobs. The queue holds up to 100 jobs in Holding status, and 5 can be active or queued at once. When a chain link is enqueued and those 5 slots are full, it waits in Holding.

This means a long chain competes with your batch jobs and every other queueable in the org. A chain of 500 links launched at the same time as three nightly batch jobs will run slowly, and the symptom is a job that is neither failed nor finished.

Check the Apex Flex Queue page in Setup when a chain seems stalled. It is usually the answer.

## Monitoring a chain

Give the chain an identity. Pass a run identifier through the constructor and stamp it on every log record:

```apex
public ChunkProcessor(List<Id> remaining, String runId, Integer link) {
    this.remaining = remaining;
    this.runId = runId;
    this.link = link;
}
```

Then the next link is `new ChunkProcessor(remaining, runId, link + 1)` and a custom object row per link gives you a chain you can query. Without it you are reading `AsyncApexJob`, where every link is a separate row with the same class name and nothing tying them together.

```apex
SELECT Id, Status, ExtendedStatus, NumberOfErrors, CreatedDate
FROM AsyncApexJob
WHERE JobType = 'Queueable'
  AND ApexClass.Name = 'ChunkProcessor'
ORDER BY CreatedDate DESC
```

Implement `Finalizer` if you need guaranteed post-processing: a finalizer runs whether the queueable succeeded or threw, which is the only reliable place to record that a link failed. If the work starts from a query rather than a list you already hold, use [batch Apex](https://trailassembly.com/apex-batch-that-scales/) instead and get the monitoring for free.

## Frequently asked

**Can a queueable enqueue a batch job?** Yes, and that counts against flex queue capacity the same way.

**How do I add a delay between links?** `System.enqueueJob(job, delay)` accepts a delay in minutes, up to 10. Useful for rate-limited integrations.

**Does a chain link see the previous link’s state?** Only what you pass through the constructor. There are no statics across transactions.

**Why does my chaining test fail?** Tests cannot enqueue from a queueable. Guard the chain call and test the next link by constructing it directly.

---

Source: https://trailassembly.com/queueable-chaining-limits/
Trail Assembly — independent Salesforce write-ups.
