---
title: "Why your batch job is late: the async queue"
url: "https://trailassembly.com/async-queue-flow-control/"
topic: "Architecture"
published: "2026-09-21T11:44:59+00:00"
updated: "2026-09-23T10:53:20+00:00"
---

# Why your batch job is late: the async queue

**Briefly:** your batch job is not broken, it is queued. Salesforce runs asynchronous Apex through a shared queue with a concurrency ceiling, and when the instance is busy or your org has too much queued work, jobs sit in Holding or Queued status instead of running. Flow control is the mechanism that decides when they get to start, and it is deliberately invisible — which is why the first reaction is always to assume a bug.

## The statuses, and what each one means

| Status | Meaning |
| --- | --- |
| Holding | Accepted but not yet placed in the run queue. This is flow control. |
| Queued | In the queue, waiting for a slot. |
| Preparing | start is running; the QueryLocator is being built. |
| Processing | Chunks are executing. |
| Aborted / Failed / Completed | Terminal. |

A job sitting in Holding for twenty minutes is not a failure and there is nothing in the debug log to find, because nothing has run yet. Monitor it in Setup → Apex Jobs, or query `AsyncApexJob`.

## The ceilings that produce the wait

Two limits do most of the work here. Up to five batch jobs run concurrently per org, and up to 100 batch jobs can be Holding at once. Exceed the holding limit and `Database.executeBatch` throws outright rather than queueing.

Beyond your org’s own ceilings, the instance applies fair-usage controls. If the instance is under load from every tenant on it, your work waits — and there is no setting, no case, and no code change that changes that. Designing as if async work starts immediately is the actual mistake.

## Why this bites hardest at exactly the wrong time

Month end, a large data load, and a release all generate async work at once, and they tend to coincide. The job that has run at 2am for two years without anybody watching is the one that silently does not run on the busiest night of the quarter.

The failure mode is nasty because it is not a failure. Nothing errors, nothing alerts, and the first symptom is a report being wrong the next morning.

## Designing for a queue you do not control

**Never chain a user’s expectation to async completion.** If a screen says “your export is ready”, it has to be driven by the job actually finishing (a platform event, a record status, a push notification) not by a timer that assumes thirty seconds.

**Make jobs idempotent.** A job that may start late, be aborted, or be run twice should produce the same result each time. Work from a queryable “needs processing” flag and clear it as you go, rather than from an assumption about when the job ran.

**Do not schedule everything at midnight.** Five jobs at 00:00 fill your concurrency ceiling and the rest hold. Stagger start times, and give the long job the earliest slot.

**Watch for your own self-inflicted queue.** A trigger that enqueues per record can put thousands of jobs in the queue from one data load, and then your own scheduled work is behind them. This is by far the most common cause of “the instance is slow” that turns out to be one org’s code.

## Detecting a job that did not run

Alert on absence, not on failure. A job that failed is visible. A job that never started is not.

```apex
SELECT Id, ApexClass.Name, Status, CreatedDate, CompletedDate,
       TotalJobItems, JobItemsProcessed, NumberOfErrors
FROM AsyncApexJob
WHERE JobType IN ('BatchApex','Queueable','ScheduledApex')
  AND CreatedDate = LAST_N_DAYS:1
ORDER BY CreatedDate DESC
```

The check that matters is a scheduled query for jobs still in Holding or Queued beyond a threshold you consider abnormal for your org:

```apex
SELECT Id, ApexClass.Name, Status, CreatedDate
FROM AsyncApexJob
WHERE Status IN ('Holding','Queued')
  AND CreatedDate < :System.now().addMinutes(-30)
```

Thirty minutes is a starting point, not a rule. Measure your own normal for a fortnight before you pick the number, because an org with heavy nightly batch work has a legitimately different baseline from one that runs three jobs a day.

## Reducing time spent waiting

- **Fewer, larger jobs.** One batch at scope 2,000 occupies one concurrency slot. Ten batches at scope 200 occupy all five and queue the rest. This also helps with [the daily async limit](https://trailassembly.com/daily-async-apex-limit/).

- **Queueable instead of Batch for small work.** Queueable jobs are not subject to the five-concurrent-batch ceiling in the same way, so short work does not need to compete for batch slots.

- **Chain rather than fan out.** A queueable that enqueues its successor occupies one slot at a time. Enqueuing fifty jobs at once occupies the queue and delays everything behind it, including your own later work.

- **Abort the zombies.** Jobs stuck in Queued from an old deployment hold capacity. Setup → Apex Jobs, or `System.abortJob`.

## Frequently asked

### Is Holding status a problem?

Not by itself. It is the normal path for a batch job when the queue is busy. It becomes a problem when the hold outlasts the window the job needed to run in, which is a design question about scheduling rather than an error to debug.

### Can I raise the concurrent batch limit?

No. Five concurrent batch jobs is fixed. The lever you have is making each job do more, not running more of them.

### Why did executeBatch throw instead of queueing?

You were already at 100 jobs in Holding. That is nearly always self-inflicted. Code enqueueing per record rather than per batch.

### Do queued jobs count against the daily async limit?

They count when they execute, not while they wait. A job that holds all night and runs in the morning spends the budget in the morning.

### Does a scheduled job that could not start retry?

No. If a scheduled Apex job cannot start, that occurrence is simply missed and the next scheduled occurrence is the next chance. This is the strongest argument for alerting on absence.

---

*Reference: [Using Batch Apex](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_batch_interface.htm), [Queueable Apex](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_queueing_jobs.htm) and [Execution Governors and Limits](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_gov_limits.htm), Salesforce Apex Developer Guide.*

---

Source: https://trailassembly.com/async-queue-flow-control/
Trail Assembly — independent Salesforce write-ups.
