Jobs and handlers

A job is a name plus a JSON payload. A handler is the function that does the work. This page covers every way to connect the two, everything a handler can do, and every option you can set when enqueueing.

Three ways to define a job

StyleBest forCall site
queue.define() Most apps. No configuration. Payload type inferred. uploadPhoto({ uri })
@queue.handler Service classes with injected dependencies. queue.enqueue(svc.upload, { uri })
By name (low level) Dynamic job types, or when you cannot import the job. The payload is not type-checked. queue.enqueue("uploadPhoto", { uri })

All three produce the same stored job. You can mix them in one queue.

queue.define()

define(type, handler, defaults?) registers the handler and returns a JobFn: a function that enqueues the job, with a .type property.

Catching duplicate job names

A job type is just a string, and each type has exactly one handler. If two different define() calls use the same name, the later one silently wins:

The payload types may disagree too, so a's payload can reach handlerB unchecked. With strict this is an error, and strict is on by default outside development builds:

Habits that prevent collisions in the first place:

By name (low level)

queue.enqueue(type, payload) and queue.register(type, handler) work with plain strings. They are what define() is built on. Use them when the job type is dynamic, or when the code that enqueues cannot import the job. The payload is typed unknown, so nothing checks it: prefer define() whenever you can.

You can also register a handler later, from any module:

Class methods with decorators

Decorate a method with @queue.handler(type, defaults?). The original method becomes the handler, bound to the instance. Setup is on Getting started.

Method kindRegistered whenthis
Instance methodEach time the class is instantiatedThat instance
static methodOnce, when the class is definedThe class
One instance per class

A job type maps to exactly one handler. If you create several instances, the last one wins and receives every job, including jobs enqueued through earlier instances. Keep a singleton, or use static methods. With strict: true the second instance throws instead (see duplicate names).

Enqueueing a method that is not decorated throws: enqueue: method is not decorated with @queue.handler.

Handlers

A handler is (payload, ctx) => unknown. The return value is ignored: a job has no result. Success means the handler returned or its promise resolved. Failure means it threw or rejected.

Sync, async and generator forms

Generators (function* and async function*) run to completion. If the job is aborted, the generator is closed at the next yield, so finally blocks run and resources are released. A generator that throws is retried from the beginning.

Sync handlers cannot be interrupted

A long synchronous function blocks the thread until it returns, so neither the timeout nor remove() can act in the middle. Prefer async or generator handlers for anything slow.

The context (second argument)

FieldDescription
ctx.jobThe job: id, type, payload, attempt counters, timestamps.
ctx.attempt1-based attempt number. 1 on the first run.
ctx.signalAn AbortSignal that fires on timeout or when the job is removed.
ctx.progress(n)Report progress from 0 to 1. Saved on job.progress.
ctx.detach(id)Hand the work to a native task. return it. See Native tasks.

Using the AbortSignal

Pass the signal to anything cancellable so the work really stops when the job is aborted.

Even if a handler ignores the signal, the queue stops waiting for it when the job times out or is removed: the job's slot is freed and the outcome is decided without it. The handler's code may keep running in the background, which is why using the signal matters.

Progress

Progress belongs to the current attempt: it resets when a new attempt starts, and calls after the attempt has ended are ignored.

Read fresh state inside the handler

A job may run hours after it was enqueued. Do not put short-lived values like auth tokens in the payload. Read them when the handler runs.

Errors and what they do

You throwResult
Any ErrorCounts as a failed attempt. Retried after the exponential backoff until maxAttempts.
NonRetryableErrorThe job becomes failed immediately. No retries.
RetryAfterError(ms)Counts as a failed attempt but waits exactly ms instead of the backoff.
A non-Error (string, object)Treated like an Error. String(value) becomes lastError.
(timeout)The queue rejects with TimeoutError after timeoutMs. Retried.

There is a complete table of what to retry in Classifying failures.

Payload rules

The payload is stored as JSON. Anything JSON.stringify cannot round-trip is lost or changed.

Do not put in a payloadDo this instead
DateAn ISO string or epoch milliseconds. A Date comes back as a string.
Map, Set, class instancesPlain objects and arrays.
Functions, undefined values, symbolsThey are dropped. Send explicit values.
NaN, InfinityBecome null. Avoid.
File or image bytes, large base64 blobsSave the file to the app's storage and put its path in the payload. Payloads over maxPayloadBytes (default 64 KB) make enqueue() throw.
Auth tokens and other short-lived secretsRead them when the handler runs. If a payload must hold something private, enqueue with sensitive: true and use an encrypted storage.

Keep payloads small: they are rewritten to storage on every state change of the job. Put IDs and references in them, not data you can look up.

In development builds (__DEV__), a payload with a key containing token, password, secret or authorization logs a one-time warning. sensitive: true silences it and hides the payload (and the handler's result) from on() and observe() listeners, so it cannot reach logs or analytics. The handler still receives it, and it is still stored.

Enqueue options

Every option works at the call site (queue.enqueue or a defined job) and as a default in define() or @queue.handler.

OptionDefaultEffect
idgeneratedUse your own id. If a job with that id exists, it is returned and nothing is added.
priority0Higher runs first. Negative numbers are allowed.
maxAttemptsqueue's retry.maxAttempts (5)Total attempts before the job becomes failed.
delayMs0Do not start before this many ms from now.
ttlMsnoneDrop the job if it is still pending after this long. Emits expired.
orderKeynoneJobs with the same key run one at a time, oldest first, even across retries. See ordering.
timeoutMsqueue'sAttempt timeout for this job.
sensitivefalseHide the payload from event listeners and observe(). See payload rules.
retryqueue'sPartial retry policy for this job, e.g. { maxAttempts: 10 }.
requiresNetworktrueHold the job until the device is online.
dedupeKeynoneSkip enqueueing if a non-failed job with this key exists; return that one.

id

priority

maxAttempts

delayMs

Delays need the app running

A delayed job runs when its time comes if the app is alive, or on the next launch / background window after that. It is not a replacement for scheduled local notifications.

ttlMs

Expiry works while offline and while the job waits for a retry. Running jobs are never expired. Calling retry(id) on a failed job clears its deadline.

requiresNetwork

dedupeKey

iddedupeKey
PurposeIdentify one specific jobCollapse "the same intent"
MatchesAny job with that id, in any stateNon-failed jobs only
After the job failsStill returns the failed jobA new job can be enqueued
After the job completesA new job with that id is created (the old one is deleted)Same

What enqueue returns

A synchronous snapshot of the stored Job. It does not update later. Use hooks, getJobs() or events to follow a job.