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:

StateMeaningLeaves 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:

  1. Its runAt time has arrived (delay or backoff has elapsed).
  2. A handler is registered for its type.
  3. The device is online, or the job has requiresNetwork: false.
  4. 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.

OptionDefaultMeaning
retry.maxAttempts5Total attempts including the first.
retry.baseDelayMs1000Wait after the first failure. Doubles each time.
retry.maxDelayMs60000Upper bound for one wait.
retry.jittertrueRandomize 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 valueparseRetryAfter(value)
"120"120000
"0"0
"Thu, 01 Jan 2026 00:00:30 GMT" (30 s in the future)30000
An HTTP date in the past0
null, undefined, "", or garbageundefined

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:

SituationDoWhy
Network error (fetch rejects)Throw (retry)Transient. If the device is offline, the queue does not even count the attempt.
408 Request TimeoutThrow (retry)Transient.
429 Too Many RequestsRetryAfterError if the header exists, else throwServer asked you to slow down.
500, 502, 503, 504Throw (retry)Server trouble, usually temporary.
400, 404, 405, 410, 422NonRetryableErrorThe request itself is wrong; repeating it cannot succeed.
401Refresh the token and retry once; if it still fails, NonRetryableError or pause the queue until sign-inSee auth refresh.
409 ConflictUsually success if you use idempotency keys; otherwise NonRetryableErrorThe 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.

Network awareness

  1. A job with requiresNetwork: true (the default) does not start while the monitor reports offline.
  2. When connectivity returns, the queue wakes up immediately and starts waiting jobs.
  3. If connectivity drops during an attempt and the handler throws, the job goes back to pending without using an attempt and without a retry event. It simply waits for the network.
  4. A job with requiresNetwork: false runs 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.

No monitor, no offline logic

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:

  1. Higher priority first.
  2. Then the earliest runAt (the time it became due).
  3. Then the oldest createdAt.
Strict order needs 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

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 wasAfter restart
pendingStill 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.
failedStill failed, with its error.
Corrupt entry in storageDeleted.

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).