A TypeScript-first, durable job queue for Node/Bun — without Redis. It uses the
database you already have: SQLite by default (zero native deps via bun:sqlite),
Postgres as a drop-in for multi-server, and an in-memory backend for tests.
import { z } from "zod";
import { createQueue, defineJob, SqliteAdapter } from "queue-lite";
const sendEmail = defineJob({
name: "sendEmail",
schema: z.object({ to: z.string().email(), subject: z.string() }),
handler: async ({ payload }) => {
await mailer.send(payload.to, payload.subject);
},
});
const queue = createQueue({
storage: new SqliteAdapter("queue.db"),
jobs: [sendEmail],
worker: { concurrency: 5 },
});
await queue.start();
await queue.enqueue("sendEmail", { to: "alice@example.com", subject: "Welcome!" });
// compile error - wrong payload shape:
// await queue.enqueue("sendEmail", { to: "alice@example.com" });
// compile error - unknown job name:
// await queue.enqueue("sendEmial", { to: "...", subject: "..." });Every mature Node job queue needs Redis: friction in dev, cost in deploy, a
coordination point at scale. queue-lite removes that — jobs live in your existing
database, survive restarts, retry on failure, and run scheduled work without a cron
daemon. When you truly outgrow it, switch to Postgres by changing one line.
- Type-safe enqueue — payloads are inferred from each job's Zod schema; wrong name or shape is a compile error, and everything is validated again at runtime.
- Durable — jobs are written to storage before returning; nothing is lost on crash or deploy.
- Retries with backoff — fixed or exponential, with jitter, configurable per job.
- Delays and priorities — run a job later (
delayMs/runAt) or ahead of others. - Cron schedules — recurring jobs that fire exactly once across instances.
- Lifecycle events —
enqueued,started,completed,failed,retried,stalledfor logging/metrics/alerting. - Stalled-job recovery — leases expire and orphaned jobs are reclaimed.
- CLI — inspect and manage the queue from your terminal.
- Pluggable storage — SQLite, Postgres, Memory; one interface, easy to extend.
bun add queue-lite zod
# Postgres backend (optional):
bun add postgresimport { SqliteAdapter, PostgresAdapter, MemoryAdapter } from "queue-lite";
new SqliteAdapter("queue.db"); // file-backed (default)
new SqliteAdapter(":memory:"); // ephemeral
new PostgresAdapter(process.env.PG_URL!); // multi-server
new MemoryAdapter(); // testsSwitching backends changes only this one line — your job code is unchanged.
Because jobs live in the storage backend, a producer and a worker can run as separate processes (or machines) as long as they share storage:
| Backend | Shared across processes? | Use for |
|---|---|---|
MemoryAdapter |
No — state is in-process only | Tests, single-process apps |
SqliteAdapter |
Yes, same machine (shared file) | Single-host producer + worker(s) |
PostgresAdapter |
Yes, across machines | Multi-server / horizontal scale |
Important
MemoryAdapter does not work with a separate worker process. Each process
gets its own empty in-memory store, so a worker started in a different process
never sees jobs enqueued elsewhere. (Two Queue instances in the same process
can share one MemoryAdapter instance.) To run a worker as a separate process,
use SqliteAdapter (same machine) or PostgresAdapter (any machine).
The library runs on both Bun and Node. The SQLite adapter picks a driver automatically:
| Runtime | SQLite driver | Notes |
|---|---|---|
| Bun | bun:sqlite |
Built-in, zero deps |
| Node >= 22.5 | node:sqlite |
Built-in (older versions need --experimental-sqlite) |
| Any Node | better-sqlite3 |
Fallback, used if installed |
So new SqliteAdapter("queue.db") works the same on Bun and Node. On older Node
without node:sqlite, bun add better-sqlite3 (an optional peer) and it is used
automatically. The Postgres and in-memory backends have no runtime constraints.
// Every weekday at 9am
await queue.schedule("dailyReport", "0 9 * * 1-5", { team: "growth" });
// Run 3 days from now
await queue.enqueue("followUp", { userId }, { delayMs: 3 * 24 * 60 * 60 * 1000 });
// Jump the line
await queue.enqueue("chargeCard", { invoiceId }, { priority: 10 });
// Dedupe
await queue.enqueue("syncUser", { userId }, { idempotencyKey: `sync:${userId}` });await queue.stats(); // { pending, active, failed, ... }
await queue.list({ status: "failed", limit: 20 });
await queue.getJob(id);
await queue.history(id); // every attempt + error
await queue.retry(id); // requeue a failed job
await queue.cancel(id); // cancel a pending/delayed jobqueue-lite stats --db queue.db
queue-lite list --status failed
queue-lite show <job-id>
queue-lite retry <job-id>
queue-lite cancel <job-id>
queue-lite stats --pg "$PG_URL" --jsonUse --db <path> / QUEUE_LITE_DB for SQLite or --pg <conn> / QUEUE_LITE_PG
for Postgres, and --json for machine-readable output.
Use the in-memory adapter and a FakeClock to drive the worker deterministically:
import { createQueue, MemoryAdapter, FakeClock } from "queue-lite";
const clock = new FakeClock(0);
const queue = createQueue({ storage: new MemoryAdapter(), jobs: [myJob], clock });
await queue.enqueue("myJob", { /* ... */ });
await queue.runWorkerOnce(); // process all ready jobs, no real timers
expect((await queue.stats()).completed).toBe(1);The StorageAdapter interface and the CLI's read layer are deliberate seams, so
these land without breaking the public API:
- More databases — MySQL/MariaDB, LibSQL/Turso, MongoDB (implement the adapter and pass the conformance suite).
- Richer CLI —
work(run a worker),purge,schedules,--watchlive tail. - Web dashboard — an optional
queue-lite/webserved viaBun.serve, reusing the same adapter calls and streamingqueue.eventsover SSE.
Extreme-throughput pipelines (tens of thousands of jobs/sec) — reach for BullMQ and
Redis. queue-lite is for everything else, with a clear upgrade path.
MIT