Reliability and scheduling
How jobs move through their life, when they run, when they retry, and what happens when things go wrong. Read this page once and the queue's behaviour will never surprise you.
Job lifecycle
The diagram on the overview shows the transitions. In words:
| State | Meaning | Leaves the state when |
|---|---|---|
pending |
Saved and waiting: not due yet, offline, no handler yet, or no free slot. | It becomes due and eligible, then starts (active), or it expires or is removed. |
active |
A handler is running it, or it was detached to a native task. | The handler finishes (deleted), throws (pending or failed), or complete() / fail() is called for detached jobs. |
failed |
Out of attempts or rejected with NonRetryableError. Kept with lastError. |
retry(id) (back to pending) or remove(id). |
There is no completed state: finished jobs are deleted. Listen for the
completed event if you need to react.
A pending job is eligible to start when all of these are true:
- Its
runAttime has arrived (delay or backoff has elapsed). - A handler is registered for its type.
- The device is online, or the job has
requiresNetwork: false. - The queue is started and has a free slot (
concurrency), and its type's limits allow it.
Retries and backoff
When a handler throws, the attempt counts as failed. If attempts remain, the job goes back
to pending with its runAt pushed into the future. The wait grows
exponentially:
delay = min(maxDelayMs, baseDelayMs * 2^(failures - 1)), then, with jitter, a random value between delay / 2 and delay.
| Option | Default | Meaning |
|---|---|---|
retry.maxAttempts | 5 | Total attempts including the first. |
retry.baseDelayMs | 1000 | Wait after the first failure. Doubles each time. |
retry.maxDelayMs | 60000 | Upper bound for one wait. |
retry.jitter | true | Randomize each wait within 50% to 100% so devices do not retry in lockstep. |
Backoff calculator
Change the numbers to see exactly how long a job waits between attempts.
After the last attempt
The job becomes failed, lastError holds the final message, and the
failed event fires with the error. It stays in storage until you decide:
Retry-After and RetryAfterError
When a server tells you how long to wait (HTTP 429 or 503), use that instead of the generic
backoff. parseRetryAfter understands both header formats.
| Header value | parseRetryAfter(value) |
|---|---|
"120" | 120000 |
"0" | 0 |
"Thu, 01 Jan 2026 00:00:30 GMT" (30 s in the future) | 30000 |
| An HTTP date in the past | 0 |
null, undefined, "", or garbage | undefined |
The error still uses up an attempt, and the delay is used as given (it is not capped by
maxDelayMs), because the server knows best.
Classifying failures
The most useful retry policy is knowing which errors are worth retrying. A reasonable starting point for HTTP APIs:
| Situation | Do | Why |
|---|---|---|
| Network error (fetch rejects) | Throw (retry) | Transient. If the device is offline, the queue does not even count the attempt. |
| 408 Request Timeout | Throw (retry) | Transient. |
| 429 Too Many Requests | RetryAfterError if the header exists, else throw | Server asked you to slow down. |
| 500, 502, 503, 504 | Throw (retry) | Server trouble, usually temporary. |
| 400, 404, 405, 410, 422 | NonRetryableError | The request itself is wrong; repeating it cannot succeed. |
| 401 | Refresh the token and retry once; if it still fails, NonRetryableError or pause the queue until sign-in | See auth refresh. |
| 409 Conflict | Usually success if you use idempotency keys; otherwise NonRetryableError | The thing may already exist. |
Timeouts
timeoutMs (default 30 seconds) limits one attempt. When it elapses the
queue aborts ctx.signal, stops waiting for the handler and records a
TimeoutError ("Job timed out after 30000ms") as a normal failure, so the job
is retried.
- The timeout is a queue-wide setting. For one very slow job type, put it on its own queue (see Multiple queues) or hand it to a native task with
detach(), which has no timeout. - Pass
ctx.signalto your network calls so they actually stop.
Network awareness
- A job with
requiresNetwork: true(the default) does not start while the monitor reports offline. - When connectivity returns, the queue wakes up immediately and starts waiting jobs.
-
If connectivity drops during an attempt and the handler throws, the job goes back
to
pendingwithout using an attempt and without aretryevent. It simply waits for the network. - A job with
requiresNetwork: falseruns regardless of connectivity, and its failures use normal backoff.
"Online" with the NetInfo adapter means the device is connected and the internet is not reported unreachable. Until the first NetInfo event the queue assumes online. Other sources and custom checks are in Network monitors.
Without a network option the queue treats the device as always online.
Offline attempts then fail and burn retries, and nothing reacts to reconnecting.
Ordering and priority
Among the jobs that are eligible, the queue starts them in this order:
- Higher
priorityfirst. - Then the earliest
runAt(the time it became due). - Then the oldest
createdAt.
orderKey
Without it, if job 1 fails and waits for a retry, job 2 can run first. Give jobs that
must stay in order the same orderKey (for example a chat id):
send(msg, { orderKey: chatId }). Jobs with one key run one at a time, oldest
first, even across retries; a job that has given up (failed) no longer
blocks the ones after it. Priority is ignored within a key, and different keys still run
in parallel. retry(id) puts a failed job back at its original position.
Concurrency
concurrency (default 3) is the maximum number of jobs running at once in this
queue. Detached jobs (handed to native tasks) do not count: they hold no slot.
Per-type limits and rate limits
limits adds constraints for specific job types, on top of the queue-wide
concurrency. A type blocked by its limit never holds up other types.
The rate window is rolling and counts job starts, retries included. With
{ max: 2, perMs: 1000 } and five queued jobs, two start at t = 0, two at about
t = 1 s and the last at about t = 2 s. Jobs held back by a rate limit are woken by a timer,
no polling.
Delays and expiry
delayMspostpones the first attempt.ttlMsdrops a job that is still pending after that long (including offline). Theexpiredevent fires and the job is deleted.- Delays and deadlines are wall-clock timestamps (
Date.now()), with no clock-change protection. If the user sets the device clock back, waiting jobs wait longer; set forward, delayed jobs fire andttlMsjobs expire early. Do not rely ondelayMsorttlMsfor security-critical timing.
Crash recovery and delivery semantics
State is saved at every transition, so the queue can be killed at any moment. On the next launch:
| Job was | After restart |
|---|---|
pending | Still pending, same runAt. |
active (handler running) | Back to pending. The interrupted attempt is not counted as a failure. |
active and detached (externalId set) | Still active. Reattach to the native task. See Native tasks. |
failed | Still failed, with its error. |
| Corrupt entry in storage | Deleted. |
Because an interrupted handler runs again, QueueKit is at-least-once. The handler may have partly or fully done its work before the crash. Make the effect idempotent:
The job id is stable across attempts and restarts, which is what makes it a good idempotency key. If your backend has no idempotency support, make the operation naturally repeatable (PUT instead of POST, upsert by a client-generated id).