Skip to content
Get started
Get started
bunqueue FAQ: Bun Job Queue Questions Answered
reference · faq

Asked, answered.

One-paragraph answers on storage, modes, performance, retries, scaling and migration. If your question is not here, GitHub Discussions is the next stop.

Short answers to the questions people actually ask. Each answer links to the page that owns the topic.

bunqueue is a job queue for Bun: you push jobs (units of work, like “send this email”) onto a named queue, and workers pull and process them with retries, priorities, and scheduling. It persists jobs in SQLite (a database stored in a single local file) instead of Redis, and its API is compatible with BullMQ, so migration is mostly an import change. Start with the quickstart.

One less server. There is nothing to install, monitor, or pay hosting for, jobs survive restarts by default, and backup is copying one file (or letting the built-in S3 backup do it). Bun’s native SQLite bindings make it fast enough that bunqueue beats BullMQ on bulk throughput; see the comparison. The trade-off is that the queue is single-instance, covered under Scaling below.

The server is Bun-only (bun:sqlite, Bun.serve, Bun.listen do not exist in Node), but your producers and workers run anywhere: official SDKs exist for TypeScript (Node.js, Deno, Bun, Cloudflare Workers), Python, PHP, Go, Rust and Elixir, all speaking the same TCP protocol.

Bun 1.3.9 or newer (enforced via the package engines field) on macOS, Linux, or Windows via WSL. An SSD helps write throughput. Install steps are on the installation page.

Two runtime dependencies: croner (cron parsing) and msgpackr (binary serialization). bun add bunqueue installs 7 packages totaling about 5.5 MB. The MCP SDK is an optional peer dependency: only install @modelcontextprotocol/sdk if you use the MCP server, the launcher tells you if it is missing. Queue and Worker users never need it.

What is the difference between embedded and server mode?

Section titled “What is the difference between embedded and server mode?”

Embedded mode (new Queue('q', { embedded: true })) runs the queue inside your process: no network, best when producers and workers live in one app. Server mode runs bunqueue start as a standalone process and clients connect over TCP: best when multiple processes or machines share queues. The API is the same in both. Pick one mode per deployment; the same SQLite file must never be opened by two processes at once.

Nowhere, unless you say so. Without a data path, jobs are held in memory and lost on restart. Set dataPath in the constructor for embedded mode, or BUNQUEUE_DATA_PATH (also accepted: BQ_DATA_PATH, DATA_PATH, SQLITE_PATH) or --data-path for the server:

Terminal window
BUNQUEUE_DATA_PATH=./data/production.db bunqueue start

Jobs are written to SQLite in WAL mode (write-ahead logging, a journal that lets reads and writes overlap). By default writes are buffered for up to 10ms and flushed in batches, which is what enables ~100k jobs/sec. If a 10ms loss window is unacceptable for a job, add it with { durable: true } to write it to disk before add() returns (~10k jobs/sec). Details in configuration.

No, bunqueue is SQLite-only by design: the storage layer is built on synchronous bun:sqlite, and swapping the database would not add clustering to a single-instance engine. On serverless or ephemeral filesystems, mount a persistent volume for the data path, or forward jobs to a central durable server. See storage backends.

Enable the built-in S3 backup (S3_BACKUP_ENABLED=1 plus bucket and credentials, it uploads on an interval and prunes old copies), or take manual snapshots with sqlite3 queue.db ".backup backup.db". Restore with bunqueue backup list and bunqueue backup restore <key> --force. Full guide: backup.

In the default buffered mode, around 100k jobs/sec sustained pushes, with up to a 10ms data-loss window if the process dies mid-flush. In durable mode (write confirmed to disk per job), around 10k jobs/sec with no loss window. Bulk and concurrent pushes go higher, embedded mode higher still. Dated, reproducible methodology lives on the benchmarks page.

Three knobs, in order of impact: raise worker concurrency (parallel jobs per worker), batch your inserts with queue.addBulk(jobs) (one round-trip for many jobs), and raise the worker’s batchSize so it pulls and acknowledges jobs in batches. In TCP mode, concurrent queue.add() calls are also auto-batched for you by default. See worker options.

Pass a jobId. Adding the same jobId twice returns the existing job instead of creating a duplicate, same behavior as BullMQ. This makes webhook handlers and restart-recovery code safe to re-run:

await queue.add('charge', data, { jobId: `order-${orderId}` }); // idempotent

attempts sets the maximum tries, backoff sets the wait between them. A plain number means exponential backoff (waits roughly double each retry: ~2s, ~4s, ~8s with a 1000ms base); the object form { type: 'fixed' | 'exponential', delay } matches BullMQ. All delays get automatic jitter, a small random spread so thousands of failed jobs do not retry in the same instant, and are capped at 1 hour by default.

await queue.add('task', data, { attempts: 5, backoff: 1000 });

What happens when a worker crashes mid-job?

Section titled “What happens when a worker crashes mid-job?”

Workers send heartbeats (periodic “still alive” pings). If one goes silent, stall detection marks its active jobs as stalled and requeues them. A job that stalls too many times goes to the dead letter queue instead of looping forever. See stall detection.

The DLQ is the holding area for jobs that exhausted their retries, threw an unrecoverable error, or stalled too many times. Nothing is silently dropped: you can inspect entries, retry them (queue.retryDlq()), or purge them. It also supports auto-retry and expiration policies. See DLQ.

FIFO (first in, first out) is the default for jobs of equal priority, so ordered processing needs no options. Use { priority: 10 } to jump the line (higher runs sooner), or { lifo: true } for the newest-first LIFO partition. At equal numeric priority, LIFO jobs run ahead of FIFO jobs; FIFO entries retain oldest-first order within their partition.

Yes. In server mode any number of worker processes, on any machines, connect over TCP and share the queues. That is the standard way to scale processing: the queue server stays single, the workers multiply.

Does bunqueue support clustering or high availability?

Section titled “Does bunqueue support clustering or high availability?”

No built-in clustering: bunqueue is single-instance, one server process owns the SQLite file. For disaster recovery, use S3 backups plus restore on a replacement host. For edge or multi-site setups, run local embedded queues and use store-and-forward (queue.forward()) to drain jobs to a central server. If the queue itself must scale across servers, BullMQ on Redis Cluster is the better fit; see the comparison.

Yes. Retries with backoff, stall detection, a dead letter queue, rate limiting, native TLS, Prometheus metrics, and S3 backups are all built in. The production guide covers deployment, sizing, and operations.

Yes, and it is usually small: change the import to bunqueue/client, delete the Redis connection, add embedded: true (or a TCP connection). Queue, Worker, QueueEvents, FlowProducer, job options, and events keep the same shapes. The real differences (backoff shorthand, repeat.cron renamed to pattern, boolean-only removeOnComplete) are listed in the migration guide.

There is no importer, but the job format is plain JSON. Export your jobs and bulk-insert them:

await queue.addBulk(oldJobs.map((j) => ({
name: j.type,
data: j.payload,
opts: { priority: j.priority },
})));

What is the Workflow Engine, and when do I use it over FlowProducer?

Section titled “What is the Workflow Engine, and when do I use it over FlowProducer?”

FlowProducer builds parent-child job trees: fan out children, run the parent when they finish. The Workflow Engine is a step-by-step orchestrator for business processes: ordered steps with per-step retry, conditional branching, parallel blocks, loops, saga compensation (automatic rollback of completed steps when a later one fails), and waitFor signals for human approval. Use FlowProducer for job dependency graphs, Workflow for processes with rollback, branching, or human decisions. See workflow and flows.

Yes. Execution state (current step, step results, received signals) is persisted in SQLite, so workflows resume after a restart and can be inspected at any time. The Engine works in both embedded and TCP server mode, it accepts the same connection options as Queue.

Two processes are writing to the same SQLite file. Run exactly one embedded instance per file, or switch to server mode so all processes go through one server. More cases in troubleshooting.

The job was already removed: it completed with removeOnComplete: true, failed with removeOnFail: true, or was deleted manually. Completed-job records are also bounded in memory, so very old results eventually age out.

Usually accumulation: completed jobs kept around, a growing DLQ, or oversized job payloads (keep payloads small, store big blobs elsewhere and pass a reference). Add jobs with removeOnComplete: true, purge the DLQ periodically (queue.purgeDlq()), and clean old jobs with queue.clean(3600000, 1000) (grace period in ms, max jobs to remove).

Report bugs on GitHub Issues, propose features in Discussions, or send a PR. To develop locally: clone the repo, bun install, bun test.