Docs / Packages / @zudojs/queue
v1.4.1

@zudojs/queue

Background job and asynchronous task infrastructure. Provides an in-memory queue with processors, workers, retry policies, dead letter handling, middleware, serialization, and event emission.

QUEUE JOBS WORKERS BACKGROUND RETRY

OVERVIEW

Some work should not happen while a user waits. Sending an email, resizing an image or calling a slow third-party API can take seconds, and if it fails you want to try again later. A job queue is a list of such tasks that your program works through in the background, one at a time or a few at once.

@zudojs/queue gives you that list. You add a job (a name plus some data), you register a processor (the function that does the work for that name), and the queue runs the processor for each job. If the processor throws, the queue can retry with a delay and, when every attempt fails, park the job in a dead-letter store so it is not silently lost.

Everything in this package runs in memory inside one Node.js process. Jobs disappear when the process exits. That is ideal for development, tests and small apps; a durable backend would need a custom Queue implementation.

In plain words: a queue is a to-do list, a job is one item on it, and a processor is the function that ticks the item off.
When you need it
  • → Work that can finish after the request returns (emails, exports, webhooks)
  • → Work that fails sometimes and should be retried automatically
  • → Limiting how many slow tasks run at the same time
  • → Running a task once, later (delay)
When you don't
  • → The caller needs the result right now: just await the function
  • → The task repeats on a timetable ("every night at 2am"): use @zudojs/scheduler
  • → Jobs must survive a crash or restart: this package is in-memory only

INSTALLATION

Install the package. The three packages it depends on are pulled in automatically.

$ npm install @zudojs/queue
These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.
Dependencies: @zudojs/errors (error classes), @zudojs/constants (ID and timestamp types) and @zudojs/serialization (JSON payload handling). Import error classes from @zudojs/errors; this package does not re-export them.

QUICK START

This example creates a queue, registers a processor for jobs named "send-email", adds one job, and waits for it to finish.

import { createInMemoryQueue, createQueueName } from "@zudojs/queue"; interface EmailData { to: string; subject: string; } const queue = createInMemoryQueue<EmailData>(createQueueName("emails")); // 1. Say what to do for jobs called "send-email". queue.process("send-email", async (job) => { console.log(`Sending "${job.data.subject}" to ${job.data.to}`); }); // 2. Put a job on the queue. const job = await queue.add("send-email", { to: "ada@example.com", subject: "Welcome", }); console.log(job.state); // "waiting" // 3. Give the queue a moment to run it. await new Promise((resolve) => setTimeout(resolve, 200)); const finished = await queue.getJob(job.id); console.log(finished?.state); // "completed" await queue.close();

What you should see: waiting, then the "Sending ..." line, then completed. The queue starts polling for work the moment you call process(), so you never have to start it by hand.

Process lifetime: since v1.4.0, pending work keeps Node running. While the queue has waiting, delayed, retrying or running jobs that one of its processors can run, its poll timer is referenced, so a script that only calls process() and add() runs the job before it exits. An idle queue, a paused one, jobs no processor handles and a closed queue never hold the process open. Pass keepAlive: false in QueueOptions to get the old unreferenced timer back. (Before v1.4.0 such a script exited with code 0 before the job ran.)
Watch out: the first argument to createInMemoryQueue is a QueueName, not a plain string. Wrap the name in createQueueName() or TypeScript will reject it.

JOBS AND PROCESSORS

A job is one unit of work. It has a name (which processor should run it), data (the input), and a state that the queue moves through as work happens. You never build a job by hand; queue.add() builds it and returns it.

A processor is an async function registered with queue.process(name, fn). The queue calls it with two arguments: the job, and a JobContext. The context carries an AbortSignal that fires if the job times out or the queue shuts down, the 1-based attemptNumber, and an updateProgress() method.

Inside a processor, job.attempt counts the attempts that have already failed, so it starts at 0: a job with attempts: 3 runs with job.attempt equal to 0, then 1, then 2. For a human-readable count use the context's attemptNumber (new in v1.4.0), which is 1-based and always job.attempt + 1, the same convention as ctx.attempt in @zudojs/scheduler. job.maxAttempts holds the total.

import { createInMemoryQueue, createQueueName, createFixedBackoff } from "@zudojs/queue"; const queue = createInMemoryQueue(createQueueName("flaky")); queue.process("sync", async (job, ctx) => { console.log(`job.attempt=${job.attempt} attempt ${ctx.attemptNumber} of ${job.maxAttempts}`); if (ctx.attemptNumber < 3) throw new Error("not yet"); }); await queue.add("sync", {}, { attempts: 3, backoff: createFixedBackoff(10) }); await new Promise((resolve) => setTimeout(resolve, 300)); // job.attempt=0 attempt 1 of 3 // job.attempt=1 attempt 2 of 3 // job.attempt=2 attempt 3 of 3 await queue.close();

Job states

The JobState enum lists every state. Its values are lowercase strings, so job.state === "completed" works.

StateMeaning
waitingAdded and ready to run
scheduledAdded with a delay or scheduledAt; not due yet
activeA processor is running it right now
completedThe processor returned normally
failedThe processor threw (briefly, before retry or dead-letter)
retryingFailed, waiting out the backoff delay before the next attempt
dead_letterEvery attempt failed; the job is in the dead-letter store

Reporting progress and honouring cancellation

This processor reports progress with createJobProgress() and stops early if the signal is aborted.

import { createInMemoryQueue, createQueueName, createJobProgress, createJobResult, } from "@zudojs/queue"; const queue = createInMemoryQueue<{ pages: number }>(createQueueName("reports")); queue.process("build-report", async (job, ctx) => { const started = Date.now(); for (let page = 1; page <= job.data.pages; page++) { if (ctx.signal.aborted) return; await ctx.updateProgress(createJobProgress((page / job.data.pages) * 100)); } return createJobResult("report.pdf", Date.now() - started); }); await queue.add("build-report", { pages: 4 }, { timeout: 5000 }); await new Promise((resolve) => setTimeout(resolve, 200)); console.log((await queue.getStats()).succeeded); // 1 await queue.close();

A processor returns either nothing or a JobResult, built with createJobResult(data, durationMs) or createJobErrorResult(message, durationMs). Only a result with success: false, or a thrown error, counts as a failure. Each job gets a 30-second timeout unless you pass timeout.

Common mistake: adding a job whose name has no processor. The job sits in waiting forever and nothing warns you. Register the processor first, or check queue.getProcessor(name).

JOB OPTIONS

The third argument to queue.add() is a JobOptions object. Every field is optional. You can also set defaults for the whole queue with QueueOptions.defaultJobOptions.

OptionWhat it doesDefault
attemptsHow many times to try before giving up1 (no retry)
backoffHow long to wait between attempts (see Retries)exponential, 1s to 30s
delayMilliseconds to wait before the job may run0
scheduledAtA Date before which the job may not runnow
priorityHigher numbers run first; within a priority, jobs run in the order they became runnable (when added, or when a delayed job came due). A retried job keeps its place50
timeoutMilliseconds before a running job is aborted and failed. A timeout aborts context.signal; the job's slot and its retry wait up to timeoutGraceMs (default 5000) for the processor to stop, so honour the signal.30000
deduplicationKeyReject a second job with the same key while the first existsnone
metadataAny extra data you want stored on the job. The key zudo:context is reserved by the queue and stripped from whatever you pass (see below)none
Reserved key: metadata["zudo:context"] (exported as CONTEXT_METADATA_KEY) belongs to the queue's context carriers. Since v1.3.0 the queue drops whatever the caller put there before storing the job, whether or not a carrier captured anything. Before v1.3.0, an add() made with no ambient context kept the caller's record verbatim and the queue replayed it around the middleware and the processor — so an enqueuer could choose the tenant, correlation id or trace the job ran under. Context that a carrier genuinely captured is unaffected; put your own data under any other key.

Dates and other non-JSON values in data

add() copies the payload through the queue's serializer. Since v1.4.0 the default JsonSerializer preserves types, so a Date in data reaches the processor as a Date, matching what Queue<{ dueAt: Date }> promises. BigInt, Map, Set, Uint8Array and Error round-trip too. Output for plain JSON data is unchanged. No options are needed:

import { createInMemoryQueue, createQueueName } from "@zudojs/queue"; interface Reminder { userId: string; dueAt: Date; tags: Set<string>; } const queue = createInMemoryQueue<Reminder>(createQueueName("reminders")); queue.process("remind", async (job) => { console.log(job.data.dueAt instanceof Date, job.data.dueAt.toISOString(), job.data.tags.has("vip")); }); await queue.add("remind", { userId: "u1", dueAt: new Date("2026-10-01T09:00:00Z"), tags: new Set(["vip"]), }); await new Promise((resolve) => setTimeout(resolve, 200)); // true 2026-10-01T09:00:00.000Z true await queue.close();

The default uses the tagged format of @zudojs/serialization. createJsonSerializer() also defaults to preserveTypes: true; pass serializer: createJsonSerializer({ preserveTypes: false }) for plain JSON, where a Date becomes its ISO string (type such fields as string). Class instances still lose their prototype through JSON; to keep them, pass serializer: PassthroughSerializer (or serializePayloads: false) and the in-memory queue stores the payload by reference. Before v1.4.0 the default was plain JSON: a Date arrived as a string, a BigInt made add() throw, and a Map or Set arrived as {}.

This example uses priority, delay and deduplication together. JobPriorityLevels is a set of named numbers you can use instead of guessing.

import { createInMemoryQueue, createQueueName, JobPriorityLevels, } from "@zudojs/queue"; const queue = createInMemoryQueue<{ orderId: string }>(createQueueName("orders")); await queue.add("charge", { orderId: "o-1" }, { priority: JobPriorityLevels.LOW }); await queue.add("charge", { orderId: "o-2" }, { priority: JobPriorityLevels.HIGH }); const next = await queue.getNextJob(); console.log(next?.data.orderId); // "o-2" (HIGH = 100 beats LOW = 10) const later = await queue.add("charge", { orderId: "o-3" }, { delay: 60_000 }); console.log(later.state); // "scheduled" await queue.add("charge", { orderId: "o-4" }, { deduplicationKey: "order:o-4" }); try { await queue.add("charge", { orderId: "o-4" }, { deduplicationKey: "order:o-4" }); } catch (error) { console.log((error as Error).message); // Duplicate job detected with key "order:o-4". } await queue.close();

getNextJob() only peeks; it does not change the job. The duplicate add() throws a JobDuplicateError from @zudojs/errors.

RETRIES AND BACKOFF

A retry means running the same job again after it fails. Backoff is the pause before each retry. Pausing matters: if a service is down, hitting it again instantly just fails again, and hundreds of jobs doing that at once make things worse.

Set attempts to more than 1 and the queue retries. Add a backoff to control the pause. Two shapes exist: fixed (same pause every time) and exponential (the pause doubles each time, up to maxDelay). A jitter setting adds randomness so failed jobs do not all retry in the same instant.

This processor fails twice and succeeds on the third attempt, with a 50 ms fixed pause between tries.

import { createInMemoryQueue, createQueueName, createFixedBackoff, } from "@zudojs/queue"; const queue = createInMemoryQueue(createQueueName("flaky")); let attempts = 0; queue.process("call-api", async () => { attempts++; if (attempts < 3) throw new Error("Temporary failure"); }); await queue.add("call-api", {}, { attempts: 3, backoff: createFixedBackoff(50), }); await new Promise((resolve) => setTimeout(resolve, 500)); const stats = await queue.getStats(); console.log(attempts, stats.retried, stats.succeeded); // 3 2 1 await queue.close();

You can also compute delays yourself. calculateRetryDelay(attempt, backoff) is the function the queue uses internally.

import { createExponentialBackoff, calculateRetryDelay } from "@zudojs/queue"; const backoff = createExponentialBackoff(1000, { maxDelay: 5000, multiplier: 2 }); console.log(calculateRetryDelay(1, backoff)); // 1000 console.log(calculateRetryDelay(2, backoff)); // 2000 console.log(calculateRetryDelay(3, backoff)); // 4000 console.log(calculateRetryDelay(4, backoff)); // 5000 (capped by maxDelay)
Tip: if you set attempts but no backoff, the queue uses exponential backoff starting at 1 second, capped at 30 seconds, with full jitter. Retries never happen with zero delay by accident.

DEAD LETTER

When a job has used all its attempts, the queue does not delete it. It moves the job to a dead-letter store: a holding area for jobs that could not be done. You can inspect them, log them or re-add them by hand. The term comes from postal services, where undeliverable mail goes to a "dead letter office".

Every queue has an in-memory dead-letter store by default. Read it with getDeadLetterJobs().

import { createInMemoryQueue, createQueueName } from "@zudojs/queue"; const queue = createInMemoryQueue(createQueueName("broken")); queue.process("always-fails", async () => { throw new Error("Persistent failure"); }); const job = await queue.add("always-fails", {}, { attempts: 1 }); await new Promise((resolve) => setTimeout(resolve, 300)); const dead = await queue.getDeadLetterJobs(); console.log(dead.length); // 1 console.log(dead[0].reason); // "Persistent failure" console.log(dead[0].attempts); // 1 console.log((await queue.getJob(job.id))?.state); // "dead_letter" await queue.close();

Each entry is a DeadLetterJob: a copy of the job as it was when it ran out of attempts (so its state still reads "failed"), the error, the number of attempts, and a reason string. To keep dead jobs somewhere else, pass your own DeadLetterStore as QueueOptions.deadLetterStore; createInMemoryDeadLetterStore() shows the shape to copy.

Retention is bounded

Since v1.3.0 the default in-memory store keeps the most recent 1000 dead-lettered jobs (DEFAULT_DEAD_LETTER_JOBS) and evicts the oldest past that. Before v1.3.0 it was unbounded, so a queue that dead-lettered steadily grew until the process ran out of memory. Build the store yourself to choose a different cap.

import { createInMemoryQueue, createQueueName, createInMemoryDeadLetterStore, } from "@zudojs/queue"; // Keep only the 50 most recent failures. const store = createInMemoryDeadLetterStore({ maxEntries: 50 }); // Or opt back into the pre-1.3.0 unbounded behaviour. const unbounded = createInMemoryDeadLetterStore({ maxEntries: Number.POSITIVE_INFINITY, }); const queue = createInMemoryQueue(createQueueName("broken"), { deadLetterStore: store, }); await queue.close(); // `store` is yours: close() leaves it alone
Changed in v1.4.1: QueueOptions.deadLetterStore is now typed DeadLetterStore<unknown>. In v1.4.0 it was DeadLetterStore<never>, so both createInMemoryDeadLetterStore() and createInMemoryDeadLetterStore<Email>() for a Queue<Email> were rejected with TS2322, and the workaround was the type argument <never>. Either store is now accepted with no annotation. A store still annotated <never> compiles, so you can drop it at your own pace.
Who clears it: close() clears the dead-letter store the queue created for itself, so a closed queue holds onto nothing. A store you passed in as deadLetterStore is left alone, as before — you own its contents and its lifetime.
Watch out: getStats().failed counts jobs currently in failed or dead_letter state. A job that will be retried shows up there for a moment too. Use deadLettered for the lifetime total.

WORKERS

A worker is a loop that repeatedly asks a queue "anything to do?", claims one job, and runs it. The in-memory queue already contains such a loop, which is why the Quick Start needed no worker. Creating a Worker switches that loop off as a consumer (queue.setAutoProcess(false)), so the worker is the only thing running jobs and worker.stop() really stops consumption. A separate Worker is useful when you want its own concurrency limit, its own middleware, start/stop control, or per-worker statistics.

A worker never runs a processor itself. It calls queue.claimNextJob() (which marks the job active so nobody else takes it) and then queue.runJob(), so retries, dead-lettering and middleware all still apply.

Start a worker, let it process a job, then stop it cleanly.

import { createInMemoryQueue, createQueueName, createWorker } from "@zudojs/queue"; const queue = createInMemoryQueue(createQueueName("images")); queue.process("resize", async () => {}); const job = await queue.add("resize", {}); const worker = createWorker("worker-1", queue, { concurrency: 2, pollInterval: 5, }); await worker.start(); console.log(worker.state); // "running" await new Promise((resolve) => setTimeout(resolve, 200)); await worker.stop(); // waits for in-flight jobs, up to drainTimeout (30s) console.log((await queue.getJob(job.id))?.state); // "completed" console.log(worker.getStats()); // { processed: 1, succeeded: 1, failed: 0, concurrency: 2, state: "stopped" } await queue.close();

Worker options

OptionWhat it doesDefault
concurrencyHow many jobs this worker runs at the same time1
pollIntervalMilliseconds between re-checks while idle. The in-memory queue wakes a started worker as soon as a job becomes runnable (Queue.onJobReady), so this only bounds how often an idle worker looks again100
keepAliveWhether a started worker holds the Node.js process open until stop() or forceStop(). false leaves its timers unreferencedtrue
timeoutMsTimeout for jobs that carry none of their ownqueue default (30 s)
middlewareExtra middleware run after the queue's ownnone
drainTimeoutHow long stop() waits for running jobs before forcing30000
onErrorCalled for poll failures and drain timeoutslogger.error, else process.emitWarning
loggerReceives errors when no onError is givennone
Common mistake: calling start() twice. A worker can only start from created or stopped; anything else throws WorkerLifecycleError. Check worker.isRunning() first.

MIDDLEWARE

Middleware is a function that wraps every processor call. It receives a context with the job and a next() function; calling next() runs the rest of the chain and, finally, the processor. Use it for things every job needs, such as logging or timing, without repeating code in each processor.

Pass middleware in QueueOptions.middleware. This example adds the built-in logging middleware and one custom timer.

import { createInMemoryQueue, createQueueName, createLoggingMiddleware, } from "@zudojs/queue"; import type { QueueMiddleware } from "@zudojs/queue"; const timing: QueueMiddleware = async (ctx) => { const started = Date.now(); const result = await ctx.next(); console.log(`${ctx.job.name} took ${Date.now() - started}ms`); return result; }; const queue = createInMemoryQueue(createQueueName("emails"), { middleware: [createLoggingMiddleware({ info: console.log }), timing], }); queue.process("send-email", async () => {}); await queue.add("send-email", {}); await new Promise((resolve) => setTimeout(resolve, 200)); await queue.close();

What you should see: "Job processing started" with the job details, then "send-email took 0ms", then "Job processing completed". Middleware runs in array order, outermost first.

Watch out: call next() exactly once. Calling it twice throws, because it would run the processor twice. Forgetting it entirely means the processor never runs and the job is marked completed.

EVENTS

The queue can tell you when things happen: a job was created, started, completed, failed, cancelled, or will retry, and a worker started, stopped or hit an error. Since v1.4.0 every queue has an event emitter (an object you can subscribe to) out of the box: a queue created without one gets an in-memory emitter, reachable as queue.events. Before v1.4.0 the default was a silent no-op. To share one emitter between queues, or to plug in your own, create it and hand it to the queue as QueueOptions.eventEmitter.

Subscribe with on(event, handler). It returns a function that unsubscribes.

import { createInMemoryQueue, createQueueName, createInMemoryQueueEventEmitter, createJobResult, } from "@zudojs/queue"; const emitter = createInMemoryQueueEventEmitter(); const unsubscribe = emitter.on("job:completed", ({ job, result }) => { console.log(`${job.name} finished with`, result); }); emitter.on("job:failed", ({ job, error }) => { console.log(`${job.name} failed: ${error.message}`); }); const queue = createInMemoryQueue(createQueueName("emails"), { eventEmitter: emitter }); queue.process("send-email", async () => createJobResult("sent", 0)); await queue.add("send-email", {}); await new Promise((resolve) => setTimeout(resolve, 200)); // send-email finished with sent unsubscribe(); await queue.close();
EventPayloadWhen
job:created{ job }add() stored the job
job:started{ job }A processor is about to run
job:progress{ job, progress }The processor called updateProgress()
job:completed{ job, result }The processor returned; result is a JobResult's data, else undefined
job:failed{ job, error }The processor threw (fires on every failed attempt)
job:retrying{ job, attempt }A retry has been scheduled
job:cancelled{ job }A running job was aborted from outside — a draining worker, close(), or a consumer's signal. Not fired for a job that merely timed out
worker:started{ workerId }worker.start() brought the worker up
worker:stopped{ workerId }The worker stopped, through stop() or forceStop(). A forceStop() on a worker that never started reports nothing, so a readiness listener never sees a transition that did not happen
worker:error{ workerId, error }A worker poll failed or its drain timed out

Worker events and Queue.events

Before v1.3.0 the last four rows above were declared in QueueEventMap but nothing emitted them, so subscribing to worker:started for readiness never fired. They fire now. A worker reports its own lifecycle on the emitter belonging to the queue it consumes, reached through the new Queue.events property — you do not pass the emitter to createWorker.

import { createInMemoryQueue, createQueueName, createInMemoryQueueEventEmitter, createWorker, } from "@zudojs/queue"; const emitter = createInMemoryQueueEventEmitter(); const queue = createInMemoryQueue(createQueueName("images"), { eventEmitter: emitter, }); // Same emitter, reachable from the queue itself. queue.events?.on("worker:started", ({ workerId }) => { console.log(`${workerId} is up`); }); emitter.on("worker:stopped", ({ workerId }) => { console.log(`${workerId} is down`); }); emitter.on("job:cancelled", ({ job }) => { console.log(`${job.name} was aborted mid-run`); }); queue.process("resize", async () => {}); const worker = createWorker("worker-1", queue, { pollInterval: 5 }); await worker.start(); // worker-1 is up await worker.stop(); // worker-1 is down await queue.close();

Queue.events is optional on the Queue interface, so reach it with queue.events?.. The in-memory queue always has one: a queue created without an eventEmitter exposes its own in-memory emitter, so you can subscribe with no set-up at all:

import { createInMemoryQueue, createQueueName } from "@zudojs/queue"; const queue = createInMemoryQueue(createQueueName("emails")); queue.events?.on("job:completed", ({ job }) => console.log("completed", job.name)); queue.process("send-email", async () => {}); await queue.add("send-email", {}); await new Promise((resolve) => setTimeout(resolve, 100)); // completed send-email await queue.close();
Tip: a handler that throws does not break the job. The remaining handlers still run and processing continues. Since v1.3.0 the failure goes to logger.error when a logger is configured — either createInMemoryQueueEventEmitter({ logger }) or QueueOptions.logger, which the queue hands to the emitter it was given — and to process.emitWarning otherwise. It is never written to console. Pass onHandlerError to take it over entirely.

PAUSING, STATS AND SHUTDOWN

pause() stops the queue from starting new jobs; resume() starts them again. By default a paused queue also rejects add(); set pauseRejectsAdd: false in QueueOptions to keep accepting jobs while paused.

close() waits for running jobs (up to closeTimeout, 30 s), cancels pending timers and forgets every job. Always call it when your program shuts down. After closing, add() throws QueueDisposedError.

Use createQueueManager() when you have several queues and want to close them all at once.

import { createQueueManager, createQueueName } from "@zudojs/queue"; const manager = createQueueManager(); const emails = manager.getQueue(createQueueName("emails"), { concurrency: 5 }); const reports = manager.getQueue(createQueueName("reports")); console.log(manager.getQueueNames()); // [ "emails", "reports" ] console.log(manager.hasQueue(createQueueName("emails"))); // true await emails.pause(); console.log(emails.isPaused(), reports.isPaused()); // true false await manager.closeAll(); console.log(emails.isDisposed()); // true
Watch out: getQueue() only applies options when it creates the queue. Passing options for a queue that already exists throws a QueueError.

API REFERENCE

Everything below is exported from @zudojs/queue unless a note says otherwise.

Functions

NameWhat it doesNotes
createInMemoryQueue(name, options?)Creates a queue that stores jobs in memoryname is a QueueName; returns Queue
createQueue(name, options?)Same as aboveDeprecated alias; prefer createInMemoryQueue
createQueueName(string)Brands a string as a QueueNameAlso createJobName, createJobId and the isQueueName, isJobName, isJobId guards
createWorker(id, queue, options?)Creates a worker that polls the queueCall start() to begin
createQueueManager()Creates and tracks several queues by namegetQueue, getExistingQueue, hasQueue, getQueueNames, closeAll
createQueueRegistry()Stores queues you created elsewhereregister, get, has, getAll, unregister, clear, closeAll
createFixedBackoff(delay, options?)Backoff with the same delay each retryoptions.jitter: "none", "full" or "equal"
createExponentialBackoff(delay, options?)Backoff that multiplies each retrymaxDelay, multiplier (default 2), jitter
createBackoffOptions(type, delay, options?)Builds a BackoffOptions from a BackoffTypeUsed by the two helpers above
calculateRetryDelay(attempt, backoff?)Milliseconds to wait before the given attemptReturns 0 without a backoff
shouldRetry(attempt, maxAttempts)attempt < maxAttempts
createInMemoryQueueEventEmitter(options?)Emitter you can subscribe to with on()options.onHandlerError, options.logger (a throwing listener's error, default process.emitWarning); class InMemoryQueueEventEmitter also exported, with setLogger() and removeAllListeners()
createNoopQueueEventEmitter()Emitter that drops every eventPass as eventEmitter to silence a queue; the default was this before v1.4.0
createLoggingMiddleware(logger?)Logs start, completion and failure of each joblogger.info(message, data)
createTimeoutMiddleware(ms, onTimeout?)Fails a job that runs longer than msThe queue already applies one per job
createMiddlewareChain(middleware[])Combines several middleware into one
createInMemoryDeadLetterStore(options?)Dead-letter store backed by a Map, bounded to the most recent options.maxEntriesThe queue's default. maxEntries defaults to DEFAULT_DEAD_LETTER_JOBS (1000); Number.POSITIVE_INFINITY is unbounded
moveToDeadLetter(store, job, error, options?)Adds a job to a dead-letter storeThe queue calls this for you
createJobProgress(percent, options?)Builds a JobProgress for ctx.updateProgress()Clamps to 0..100
createJobResult(data, durationMs)Builds a successful JobResultAlso createJobErrorResult(error, durationMs)
createProcessorRegistry()Standalone name-to-processor mapNot used by Queue; the queue keeps its own
createJsonSerializer(options?)JSON serializer with space / preserveTypes (default true)Constants JsonSerializer (default, type-preserving) and PassthroughSerializer (stores payloads by reference)

Queue methods

NameWhat it doesNotes
add(name, data, options?)Stores a job and returns it, and wakes the poller and any idle worker at onceThrows when paused (by default), closed, duplicate, or the payload cannot be serialized (a cyclic object, for example)
process(name, processor)Registers the processor for a job name and starts pollingOne processor per name; a later call replaces it
getJob(id)Current copy of a job, or nullJobs are immutable; re-fetch to see new state
getNextJob()Peeks at the next runnable jobDoes not claim it
claimNextJob() / releaseJob(id) / runJob(job, options?)Building blocks for a custom workerUse createWorker unless you need these
getStats()Counts per state plus lifetime totalsprocessed, succeeded, errored, retried, deadLettered
getDeadLetterJobs()All DeadLetterJob entries
pause() / resume() / isPaused()Stop and restart processing
close() / isDisposed()Drain and shut downIdempotent; clears a dead-letter store the queue created itself, not one you supplied
eventsThe emitter this queue publishes onOptional on the interface; in-memory by default. A worker uses it to emit worker:started / worker:stopped / worker:error
onJobReady(listener)Calls listener whenever a job may have become runnable; returns an unsubscribe functionOptional on the interface; the in-memory queue implements it. A Worker subscribes on start() and unsubscribes on stop()

Queue options

NameWhat it doesDefault
concurrencyJobs the queue's own loop runs at once1
pollIntervalMilliseconds between polls of the queue's own loop. When set, every poll uses it. Either way, add(), a delayed job coming due, an elapsed retry backoff, resume() and a finished job wake the loop at onceunset: 50 ms, backing off to 2000 ms while idle
keepAliveWhether pending work (waiting, delayed, retrying or running jobs a processor can run) holds the Node.js process open. false leaves every timer unreferencedtrue
defaultJobOptionsJobOptions applied to every add()none
middlewareMiddleware run around every processor[]
eventEmitterWhere lifecycle events goan in-memory emitter, exposed as queue.events
deadLetterStoreWhere exhausted jobs goin-memory, last 1000
loggerDestination for errors with no caller to receive them; also handed to an InMemoryQueueEventEmitter so a throwing listener reaches logger.errornone (process.emitWarning)
serializer / serializePayloadsPayloads are copied through the serializer on add() (the default keeps Date, BigInt, Map, Set, Uint8Array and Error); set serializePayloads: false or use PassthroughSerializer to store by referenceJsonSerializer / true
pauseRejectsAddWhether add() throws while pausedtrue
retainSettledJobsFinished jobs kept before the oldest are dropped1000
closeTimeoutHow long close() waits for running jobs30000
stalledAfter / maxStalledCountReclaim a job left active by a dead consumer; dead-letter after N stalls0 (off) / 3
autoProcessWhether the queue's own loop claims and runs jobs; creating a Worker turns it offtrue
timeoutGraceMsAfter a timeout, how long the slot and the retry wait for the processor to settle5000
contextCarriersContext (tenant, correlation id, trace ids) carried from add() into the processor; see captureContext / runWithContext and the README section "Carrying context across the queue"none
Pickup latency: since v1.4.0, work does not wait for the next poll. add() and every other event that makes a job runnable wake the queue's loop (and any started Worker) immediately, so a job added after an idle spell starts within milliseconds instead of after up to 2 seconds, and 40 instant jobs at concurrency 1 finish in tens of milliseconds. Before v1.4.0 pollInterval applied only to the first poll.

Types and constants

NameWhat it isNotes
Queue, QueueOptions, QueueStatsThe queue interface and its option/stat shapesAlso QueueManager, QueueRegistry, QueueInfo
Job, JobOptions, BackoffOptionsA job and the options accepted by add()
Processor, JobContext, JobResult, JobProgressThe processor function type and what it receives/returns
Worker, WorkerOptions, WorkerStatsWorker interface, options and stats
QueueMiddleware, QueueMiddlewareContextMiddleware function and its ctx
QueueEventEmitter, QueueEventMapEmitter interface and the event-name-to-payload map
DeadLetterJob, DeadLetterStoreDead-letter entry and store interface
Serializerserialize(data): string / deserialize(string)Optional passthrough: true tells the in-memory queue to store payloads by reference
JobState, WorkerState, BackoffTypeEnums of lowercase string valuesBackoffType.FIXED, BackoffType.EXPONENTIAL
JobPriorityLevelsLOW 10, NORMAL 50, HIGH 100, CRITICAL 200Frozen object
DEFAULT_JOB_OPTIONS{ attempts: 1, timeout: 30000 }Also mergeJobOptions(options?)
DEFAULT_DEAD_LETTER_JOBS1000 — how many dead-lettered jobs the default store retainsOverride per store with createInMemoryDeadLetterStore({ maxEntries })
CONTEXT_METADATA_KEY"zudo:context" — the metadata key the queue's context carriers ownStripped from any metadata passed to add()
QueueLoggerWhat QueueOptions.logger / WorkerOptions.logger acceptStructurally compatible with @zudojs/logger and with console

Errors

These are thrown by the queue but live in @zudojs/errors. Import them from there.

NameWhenNotes
QueueErrorBase class; also add() on a paused queueAll others extend it
QueueDisposedErroradd(), process() or resume() after close()
JobDuplicateErrordeduplicationKey already in use
JobSerializationErrorPayload cannot be serializedCyclic objects, for example
JobTimeoutErrorA job ran past its timeoutCounts as a failed attempt
JobMaxAttemptsErrorRecorded on the job when it is dead-letteredAppears as DeadLetterJob.error
JobStalledErrorA job was reclaimed after stalledAfterNeeds stalledAfter > 0
WorkerLifecycleErrorworker.start() from the wrong state, or a drain timeout

COMMON MISTAKES

  • Passing a plain string as the queue name. TypeScript reports a type error on createInMemoryQueue("emails"). Wrap it: createQueueName("emails").
  • Importing errors from @zudojs/queue. The import is undefined at runtime and instanceof checks silently fail. Import JobTimeoutError and friends from @zudojs/errors.
  • Reading job.state from the object add() returned. Jobs are immutable snapshots, so it stays "waiting" forever. Call queue.getJob(job.id) to see the current state.
  • Expecting attempts: 3 to retry instantly. Without a backoff the default is exponential starting at 1 second, so a test that waits 200 ms sees only one attempt. Pass createFixedBackoff(10) in tests.
  • Adding class instances or cyclic objects. The default serializer keeps Date, BigInt, Map, Set, Uint8Array and Error, but class instances lose their methods and cyclic objects throw JobSerializationError. Put IDs and plain data in the job, or pass serializer: PassthroughSerializer to store payloads by reference.
  • Treating job.attempt as 1-based. It is 0 on the first run. Use the processor context's ctx.attemptNumber (1-based, equal to job.attempt + 1) for a human-readable count.
  • Forgetting close(). Jobs you never awaited are abandoned and pending retries are lost when the process exits. Close every queue (or manager.closeAll()) during shutdown.

COMPLETE EXPORT INDEX

Every name @zudojs/queue exports from its package root at v1.3.0 — 87 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.

Show all 87 exports
Classes (2)
InMemoryQueue InMemoryQueueEventEmitter
Functions (41)
assertProcessor calculateRetryDelay captureContext createBackoffOptions createExponentialBackoff createFixedBackoff createInMemoryDeadLetterStore createInMemoryQueue createInMemoryQueueEventEmitter createJob createJobContext createJobErrorResult createJobId createJobName createJobProgress createJobResult createJsonSerializer createLoggingMiddleware createMiddlewareChain createNoopQueueEventEmitter createProcessorRegistry createQueue createQueueManager createQueueName createQueueRegistry createTimeoutMiddleware createWorker incrementJobAttempt isJob isJobContext isJobId isJobName isProcessor isQueue isQueueName isWorker mergeJobOptions moveToDeadLetter runWithContext shouldRetry updateJobState
Interfaces (26)
BackoffOptions DeadLetterJob DeadLetterStore InMemoryDeadLetterStoreOptions Job JobContext JobInput JobOptions JobProgress JobResult ProcessorInfo ProcessorRegistry Queue QueueContextCarrier QueueEventEmitter QueueInfo QueueLogger QueueManager QueueMiddlewareContext QueueOptions QueueRegistry QueueStats Serializer Worker WorkerOptions WorkerStats
Type aliases (9)
BackoffStrategy JobId JobName JobPriority Processor QueueEventMap QueueMiddleware QueueName WorkerLifecycleState
Constants (6)
CONTEXT_METADATA_KEY DEFAULT_DEAD_LETTER_JOBS DEFAULT_JOB_OPTIONS JobPriorityLevels JsonSerializer PassthroughSerializer
Enums (3)
BackoffType JobState WorkerState