npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zudojs/queue

v1.6.0

Published

Background job and asynchronous task infrastructure with in-memory and adapter-based queue implementations.

Readme

@zudojs/queue

Background job and asynchronous task infrastructure with in-memory and adapter-based queue implementations.

Documentation: zudojs.oyinlola.site/docs/packages-queue · For AI agents: Markdown version, llms.txt

Installation

npm install @zudojs/queue

Quick Start

Runs as written — queue names are branded, so build one with createQueueName:

import { createInMemoryQueue, createQueueName } from "@zudojs/queue";

interface Email {
  to: string;
  subject: string;
}

const queue = createInMemoryQueue<Email>(createQueueName("emails"), {
  concurrency: 5,
  // Give jobs somewhere to log; without this `context.log()` goes nowhere.
  logger: console,
});

// A processor may return a value, a JobResult, or nothing.
queue.process("send-email", async (job, context) => {
  context.log("sending", { to: job.data.to });
  return { delivered: job.data.to };
});

await queue.add(
  "send-email",
  { to: "[email protected]", subject: "Welcome" },
  // Backoff accepts either BackoffType.FIXED or the plain string.
  { attempts: 3, backoff: { type: "exponential", delay: 500, jitter: "full" } },
);

// The queue polls in the background; give it a tick before reading counts.
await new Promise((resolve) => setTimeout(resolve, 100));

// Jobs that exhaust their attempts land here rather than vanishing.
console.log((await queue.getDeadLetterJobs()).length);
console.log(await queue.getStats());

// Draining shutdown: in-flight jobs finish before the queue tears down.
await queue.close();

Dead-letter store

Each queue keeps the most recent 1000 dead-lettered jobs in a store of its own, cleared by close(). To choose the cap, or to keep the jobs after the queue closes, pass a store as deadLetterStore. No type annotation is needed, for an untyped store or one typed for the queue's payload.

import {
  createInMemoryDeadLetterStore,
  createInMemoryQueue,
  createQueueName,
} from "@zudojs/queue";

// Keep only the 50 most recent failures.
const store = createInMemoryDeadLetterStore({ maxEntries: 50 });
const broken = createInMemoryQueue(createQueueName("broken"), {
  deadLetterStore: store,
});

// A store typed for the payload, so its entries read as DeadLetterJob<Email>.
const failedEmails = createInMemoryDeadLetterStore<Email>();
const emails = createInMemoryQueue<Email>(createQueueName("emails"), {
  deadLetterStore: failedEmails,
});

close() leaves a store you passed in alone; its contents are yours.

Features

  • In-memory queue for development and testing
  • Pluggable queue adapters (Redis, Bull, etc.)
  • Job retry with exponential backoff
  • Dead letter queue for failed jobs
  • Scheduled and delayed jobs
  • Middleware pipeline for job processing
  • Concurrency control
  • Draining shutdown with a bounded closeTimeout, then cooperative abort
  • Stalled-job reclaim (stalledAfter, maxStalledCount) for consumers that die mid-job
  • Bounded retention of settled jobs and of dead-lettered jobs, so a long-lived queue does not grow without limit

Polling and process lifetime

The queue's own consumer polls every pollInterval milliseconds when you set one; unset, it starts at 50 ms and backs off to 2000 ms while idle. It never waits out that interval when work arrives: add(), a delayed job coming due, a retry's backoff elapsing, resume() and a finished job all wake it at once. A Worker is woken the same way through queue.onJobReady, so its pollInterval only bounds how often an idle worker re-checks.

Pending work keeps the Node.js process alive. While the queue has waiting, delayed, retrying or running jobs that one of its processors can run, a script whose only work is the queue does not exit before the jobs run. A started Worker keeps the process alive until stop() or forceStop(). An idle queue, a paused one, work that no processor handles, and a closed queue never hold the process open. Pass keepAlive: false (to the queue or the worker) for the old behaviour, where every timer was unreferenced.

Payloads

Payloads round-trip through the serializer on add(), so the stored job is a copy. The default JsonSerializer preserves Date, BigInt, Map, Set, Uint8Array and Error, so a field typed Date arrives as a Date:

const reminders = createInMemoryQueue<{ at: Date }>(createQueueName("reminders"));
reminders.process("remind", async (job) => job.data.at.getTime()); // a real Date

createJsonSerializer({ preserveTypes: false }) gives plain JSON, where a Date becomes its ISO string (type it as string). PassthroughSerializer (or serializePayloads: false) stores payloads by reference, class instances included, with no copy and no serialization.

Ordering

Higher priority runs first. Within a priority, jobs run in the order they became runnable: when they were added, or for a delayed job when its delay elapsed, so a delayed job never jumps ahead of jobs that were already waiting when it came due. A retried job keeps its original place in line.

Attempts

job.attempt counts the attempts already made, so it is 0 while the first attempt runs. context.attemptNumber is the 1-based number of the attempt in progress (job.attempt + 1), the same convention as ctx.attempt in @zudojs/scheduler:

queue.process("sync", async (job, context) => {
  context.log(`attempt ${context.attemptNumber} of ${job.maxAttempts}`);
});

Events

queue.events works without configuration: a queue created without an eventEmitter gets an in-memory one.

queue.events?.on("job:completed", ({ job, result }) => {
  console.log(job.id, result);
});

job:failed fires on every failed attempt, not only the last, and a processor failure reports job.state === "failed" on a retryable attempt and on the final one alike, with job.attempt still counting the attempts before this one. What follows tells them apart:

| Attempt | job:failed job.state | Then | |---------|--------------------------|------| | Retryable (attempts left) | "failed" | job:retrying ("retrying"); after the backoff the job is "waiting" again | | Final (attempts exhausted) | "failed" | job:dead-lettered ("dead_letter") | | Stalled, reclaimed | "active" (as found), JobStalledError | the job is "waiting" again | | Stalled maxStalledCount times | "dead_letter", JobStalledError | job:dead-lettered |

To alert only on dead letters, subscribe to job:dead-lettered. It fires once per job, when the job enters the dead-letter store, with the entry's error (JobMaxAttemptsError, or JobStalledError for a stalled job) and reason (the last attempt's error message, or "Stalled N time(s)."):

queue.events?.on("job:dead-lettered", ({ job, error, reason }) => {
  alerts.page(`${job.name} ${job.id} dead-lettered: ${reason}`, error);
});

It fires even if a custom deadLetterStore.add() rejects, in step with getStats().deadLettered. Before 1.5.0 there was no dead-letter event.

Workers, timeouts and context

One consumer at a time. queue.process() registers a processor and, by default, the queue's own poller runs jobs. Creating a Worker for the queue turns that poller off as a consumer (queue.setAutoProcess(false)), so the worker's middleware, timeoutMs and concurrency apply to every job, and worker.stop() really stops consumption. Pass autoProcess: false to keep the queue from consuming before any worker exists.

Timeouts are cooperative. A timeout aborts context.signal; honour it. The job's concurrency slot and its retry wait up to timeoutGraceMs (5000 ms by default) for the processor to settle, so a retry never runs beside the attempt it replaces. A processor still running after the grace period is abandoned.

Carrying context across the queue. AsyncLocalStorage does not follow a job into the poller or worker that runs it. A QueueContextCarrier captures a value at add() into job metadata and restores it around the middleware and the processor:

import { AsyncLocalStorage } from "node:async_hooks";
import { createInMemoryQueue, createQueueName } from "@zudojs/queue";
import type { QueueContextCarrier } from "@zudojs/queue";

const tenants = new AsyncLocalStorage<{ tenantId: string }>();

const tenantCarrier: QueueContextCarrier<string> = {
  key: "tenantId",
  capture: () => tenants.getStore()?.tenantId,
  restore: (tenantId, run) => tenants.run({ tenantId }, run),
};

const jobs = createInMemoryQueue<{ id: number }>(createQueueName("reports"), {
  contextCarriers: [tenantCarrier],
});
jobs.process("build", async () => {
  console.log("building for", tenants.getStore()?.tenantId); // "acme"
});

await tenants.run({ tenantId: "acme" }, () => jobs.add("build", { id: 1 }));
await new Promise((resolve) => setTimeout(resolve, 100));
await jobs.close();

Keep captured values small and serializable (ids, not live objects). With @zudojs/tenancy, capture the tenant id and restore it with the tenant context storage's run.

The zudo:context metadata key is reserved: it is written only by the queue's own carriers and is stripped from any metadata passed to add(), so an enqueuer cannot choose the context its job runs under.

Errors with no caller to receive them (a worker's failing poll, a throwing event listener) go to logger.error when a logger is configured, and otherwise to process.emitWarning, never to console.

Use Cases

  • Background email sending
  • Image and video processing
  • Report generation
  • Webhook delivery
  • Any asynchronous workload