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
| Style | Best for | Call 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.
- Type safety: passing a payload that does not match the handler's parameter type is a compile error.
- Defaults: any enqueue option can be a default. Call-site options override them field by field.
- Redefining: defining the same type again replaces the handler. This is what makes hot reload work, but it also means an accidental duplicate name fails silently. See Catching duplicate job names.
- The name matters: it is saved with each job and links it to its handler after a restart. Renaming a type orphans jobs saved under the old name. See Migrations and the FAQ.
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:
- It applies to
define(),register()and@queue.handleralike. - Re-registering the same function reference is not a conflict.
-
The default follows React Native's
__DEV__flag. In development builds it is off, because Fast Refresh re-runs your definitions and each re-run creates a new function. In release builds and in tests (where__DEV__is not set) it is on, so a duplicate fails fast instead of silently running the wrong handler. -
A second instance of a class that uses
@queue.handlercounts as a different handler, so strict mode also enforces the one-instance rule. -
Do not call
define()inside a component or function that runs repeatedly: each call is a new function, which strict mode rejects. Define jobs once, at module level.
Habits that prevent collisions in the first place:
- Keep each job in its own module and prefix the name with the feature (
"photos.upload"). - Keep all names in one constants file (
export const Types = { upload: "photos.upload" } as const). - Import and call the function
define()returns instead of repeating the name as a string.
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 kind | Registered when | this |
|---|---|---|
| Instance method | Each time the class is instantiated | That instance |
static method | Once, when the class is defined | The 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.
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)
| Field | Description |
|---|---|
ctx.job | The job: id, type, payload, attempt counters, timestamps. |
ctx.attempt | 1-based attempt number. 1 on the first run. |
ctx.signal | An 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 throw | Result |
|---|---|
Any Error | Counts as a failed attempt. Retried after the exponential backoff until maxAttempts. |
NonRetryableError | The 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 payload | Do this instead |
|---|---|
Date | An ISO string or epoch milliseconds. A Date comes back as a string. |
Map, Set, class instances | Plain objects and arrays. |
Functions, undefined values, symbols | They are dropped. Send explicit values. |
NaN, Infinity | Become null. Avoid. |
| File or image bytes, large base64 blobs | Save 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 secrets | Read 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.
| Option | Default | Effect |
|---|---|---|
id | generated | Use your own id. If a job with that id exists, it is returned and nothing is added. |
priority | 0 | Higher runs first. Negative numbers are allowed. |
maxAttempts | queue's retry.maxAttempts (5) | Total attempts before the job becomes failed. |
delayMs | 0 | Do not start before this many ms from now. |
ttlMs | none | Drop the job if it is still pending after this long. Emits expired. |
orderKey | none | Jobs with the same key run one at a time, oldest first, even across retries. See ordering. |
timeoutMs | queue's | Attempt timeout for this job. |
sensitive | false | Hide the payload from event listeners and observe(). See payload rules. |
retry | queue's | Partial retry policy for this job, e.g. { maxAttempts: 10 }. |
requiresNetwork | true | Hold the job until the device is online. |
dedupeKey | none | Skip enqueueing if a non-failed job with this key exists; return that one. |
id
priority
maxAttempts
delayMs
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
id | dedupeKey | |
|---|---|---|
| Purpose | Identify one specific job | Collapse "the same intent" |
| Matches | Any job with that id, in any state | Non-failed jobs only |
| After the job fails | Still returns the failed job | A new job can be enqueued |
| After the job completes | A 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.