The SQLite Job Queue: Zero Infrastructure, No Redis
bunqueue.
The queue is a file.
bunqueue is a zero-infrastructure job queue: priorities, retries, cron, rate limits and a dead letter queue in one process, persisted to a single SQLite file. No Redis, no broker, nothing to operate.
1 · Start the server (skip on Bun: it runs embedded)
docker run -d --name bunqueue \ -p 6789:6789 -p 6790:6790 \ -v bunqueue-data:/app/data \ ghcr.io/egeominotti/bunqueue:latest2 · Install the client, in your language
bun add bunqueue # embedded: queue + worker in your processnpm install bunqueue-clientnpm install bunqueue-client # produce jobs from fetch handlerspip install bunqueue-clientcomposer require bunqueue/clientgo get github.com/egeominotti/bunqueue/sdk/gocargo add bunqueue-client{:bunqueue_client, path: "../bunqueue/sdk/elixir"} # Hex upcomingMIT licensed · no signup, no server to run · same API on Node.js, Deno, Bun, Python, PHP, Go, Rust, Elixir and Cloudflare Workers
live simulation: higher priority jobs jump the line, failures retry with backoff, exhausted retries land in the dead letter queue
One queue, any language.
The server owns every queue semantic, so clients stay thin and identical in spirit: add a job in one language, process it in another, same options, same guarantees. Official SDKs for TypeScript, Python, PHP, Go, Rust and Elixir, built on a formal wire protocol.
TypeScript · Node, Deno, Bun
import { Queue, Worker } from 'bunqueue-client';
const queue = new Queue('emails');await queue.add('welcome', { to: 'a@b.co' });
new Worker('emails', async (job) => { await sendEmail(job.data); return { sent: true };}, { concurrency: 10 });npm install bunqueue-client · Node, Deno, Bun and Workers
Python
from bunqueue import Queue, Worker
queue = Queue("emails")queue.add("welcome", {"to": "a@b.co"}, attempts=3)
def process(job): send_email(job.data["to"]) return {"sent": True}
Worker("emails", process, concurrency=10).run()same API, snake_case · sync producer, threaded workers
PHP
use Bunqueue\Queue;use Bunqueue\Worker;
$queue = new Queue('emails');$queue->add('welcome', ['to' => 'a@b.co'], ['attempts' => 3]);
$worker = new Worker('emails', fn ($j) => sendEmail($j->data()));$worker->run(); // or runOnce()FPM-friendly producer, CLI worker · runOnce() for cron ticks
Go
queue := bunqueue.NewQueue("emails", bunqueue.Options{})queue.Add("welcome", map[string]any{"to": "a@b.co"}, nil)
worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { return sendEmail(job.Data()) }, bunqueue.WorkerOptions{ Concurrency: 8, })worker.Run()goroutine worker pool · one dependency, go get and go
Rust
use bunqueue_client::{Queue, Worker, ConnectionOptions, WorkerOptions, JobOptions};
let queue = Queue::new("emails", ConnectionOptions::default());queue.add("welcome", data, JobOptions::default())?;
let worker = Worker::new("emails", |job| process(job.data()), WorkerOptions::default());worker.run()?;Elixir
queue = Bunqueue.queue("emails"){:ok, _job} = Bunqueue.Queue.add(queue, "welcome", %{to: "a@b.co"})
worker = Bunqueue.Worker.new("emails", fn job -> send_email(job.data) {:ok, %{sent: true}} end, concurrency: 8)
Bunqueue.Worker.run(worker)tagged tuples, OTP-friendly · Hex upcoming
Cloudflare Workers
import { Queue } from 'bunqueue-client';
export default { async fetch(req: Request, env: Env) { const q = new Queue('signups', { host: env.HOST, tls: true }); const job = await q.add('welcome', await req.json()); q.close(); return Response.json( { queued: job.id }); },};nodejs_compat flag, produce from fetch handlers · consume via Cron Triggers
CLI & AI agents
# push, watch, inspect from the terminalbunqueue push emails '{"to":"a@b.co"}'bunqueue statsbunqueue dlq list emails
# or let an agent drive it: 73 MCP toolsclaude mcp add bunqueue \ -- bunx bunqueue-mcpMCP server for Claude, Cursor and any MCP client
Write ten lines. Run.
The same ten lines in every language: a queue, a worker, a job. On Bun it all runs embedded in your process; from any other language the code talks to the server you started with one command.
Write a queue and a worker
import { Queue, Worker } from 'bunqueue/client';
const queue = new Queue('emails', { embedded: true });
new Worker('emails', async (job) => { console.log(`to ${job.data.to}`); return { sent: true };}, { embedded: true });
await queue.add('welcome', { to: 'a@b.co' }, { attempts: 3 });import { Queue, Worker } from 'bunqueue-client';
const queue = new Queue('emails');
new Worker('emails', async (job) => { console.log(`to ${job.data.to}`); return { sent: true };});
await queue.add('welcome', { to: 'a@b.co' }, { attempts: 3 });from bunqueue import Queue, Worker
queue = Queue("emails")queue.add("welcome", {"to": "a@b.co"}, attempts=3)
def process(job): print(f"to {job.data['to']}") return {"sent": True}
Worker("emails", process, concurrency=10).run()use Bunqueue\Queue;use Bunqueue\Worker;
$queue = new Queue('emails');$queue->add('welcome', ['to' => 'a@b.co'], ['attempts' => 3]);
$worker = new Worker('emails', function (Bunqueue\Job $job) { return ['sent' => true]; });$worker->run();queue := bunqueue.NewQueue("emails", bunqueue.Options{})queue.Add("welcome", map[string]any{"to": "a@b.co"}, bunqueue.JobOptions{"attempts": 3})
worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { return map[string]any{"sent": true}, nil }, bunqueue.WorkerOptions{Concurrency: 8})worker.Run()use bunqueue_client::{ConnectionOptions, JobOptions, Queue, Value, Worker, WorkerOptions};
let queue = Queue::new("emails", ConnectionOptions::default());queue.add("welcome", data, JobOptions::default())?;
let worker = Worker::new("emails", |job| Ok(Value::from(true)), WorkerOptions::default());worker.run()?;queue = Bunqueue.queue("emails")
{:ok, _job} = Bunqueue.Queue.add(queue, "welcome", %{to: "a@b.co"})
worker = Bunqueue.Worker.new("emails", fn job -> {:ok, %{sent: true}} end, concurrency: 8)
Bunqueue.Worker.run(worker)Run
bun app.ts # no server: embedded# to a@b.cobunx bunqueue start # server, oncenode --experimental-strip-types app.ts # Node 22+; or: deno run -A app.tsbunx bunqueue start # server, oncepython app.pybunx bunqueue start # server, oncephp worker.phpbunx bunqueue start # server, oncego run .bunx bunqueue start # server, oncecargo runbunx bunqueue start # server, oncemix run app.exsThat’s the whole setup, in any of the six languages. Retries, priorities, cron, rate limits and the dead letter queue are options on add(); on the server, —data-path persists everything to a single SQLite file. Full quickstart · SDK guide
Same job, three fewer boxes.
BullMQ is excellent software that requires Redis. bunqueue removes the requirement: the queue lives in your process and persists to one file. Coming from Celery, Sidekiq or asynq instead? Same trade: the broker box is the part that disappears.
Running BullMQ
4 moving partsone of them stateful, on call
Running bunqueue
1 filecp to back up, sqlite3 to inspect
Pushing 100-job batches over TCP
throughput in ops/sec vs BullMQ + Redis, identical workloads
p99 push latency, lower is better
Apple M1 Max · Bun 1.3.14 · BullMQ 5.79.3 · Redis 8.8.0 · methodology · all benchmarks →
Where bunqueue stops.
Single-instance by design: one process owns the SQLite file. That is exactly what deletes the Redis box, and it draws a clear line. Read this before you adopt.
No clustering, no failover
One server process owns the queue. Disaster recovery is S3 backup plus restore on a new host, not automatic failover. If the queue itself must span servers, BullMQ on Redis Cluster is the better fit, we say so in the comparison.
Buffered writes by default
Writes are batched for up to 10ms, that buffer is what buys ~100K jobs/sec. A hard crash inside the window can lose those jobs. Jobs that cannot tolerate it take { durable: true } and hit disk before add() returns.
Workers scale, the server does not
Any number of worker processes on any machines connect over TCP, that is the scaling path. For edge and multi-site setups, run local embedded queues and drain them to a central server with forward().
See the queue move.
A web dashboard that fully drives your server: queues, jobs, DLQ, cron, webhooks, workers, live activity, a SQLite inspector and an AI copilot. One command: bunx bunqueue-dashboard.
user guide · github · npm
One process. One file.
Point dataPath at bunq.db and everything the queue knows lives there: jobs and their states, schedules, results, the dead letter queue. Back it up with cp, inspect it with sqlite3, ship it to S3 on a schedule. Your infrastructure diagram loses three boxes, and there is no version skew between the queue and its store.
production/ ├── app/ your services └── data/ └── bunq.db the entire queue · jobs waiting · active · completed · cron schedules and repeatables · dlq failed jobs, kept · results return values
A queue and a worker, ten lines.
The same familiar Queue and Worker API in six languages, and 3.5x the bulk push throughput of BullMQ measured on identical workloads, methodology below. Migrating takes minutes, the mental model is the same.
import { Queue, Worker } from 'bunqueue/client';
const queue = new Queue('emails', { embedded: true });
await queue.add('welcome', { to: 'a@b.co' }, { attempts: 3 });
const worker = new Worker('emails', async (job) => { await sendEmail(job.data); return { sent: true };}, { embedded: true, concurrency: 5 });
worker.on('completed', (job, result) => console.log(`done: ${job.id}`));import { Queue, Worker } from 'bunqueue-client';
const queue = new Queue('emails');
await queue.add('welcome', { to: 'a@b.co' }, { attempts: 3 });
const worker = new Worker('emails', async (job) => { await sendEmail(job.data); return { sent: true };}, { concurrency: 5 });
worker.on('completed', (job, result) => console.log(`done: ${job.id}`));from bunqueue import Queue, Worker
queue = Queue("emails")queue.add("welcome", {"to": "a@b.co"}, attempts=3)
def process(job): send_email(job.data) return {"sent": True}
worker = Worker("emails", process, concurrency=5)worker.on("completed", lambda job, result: print(f"done: {job.id}"))worker.run()use Bunqueue\Queue;use Bunqueue\Worker;
$queue = new Queue('emails');$queue->add('welcome', ['to' => 'a@b.co'], ['attempts' => 3]);
$worker = new Worker('emails', function (Bunqueue\Job $job) { sendEmail($job->data()); return ['sent' => true];});$worker->on('completed', fn ($job, $r) => print("done: {$job->id()}\n"));$worker->run();queue := bunqueue.NewQueue("emails", bunqueue.Options{})queue.Add("welcome", map[string]any{"to": "a@b.co"}, bunqueue.JobOptions{"attempts": 3})
worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { return sendEmail(job.Data()) }, bunqueue.WorkerOptions{Concurrency: 5})
worker.On("completed", func(args ...any) { job := args[0].(*bunqueue.Job) log.Printf("done: %s", job.ID())})worker.Run()use bunqueue_client::{ConnectionOptions, JobOptions, ProcessError, Queue, Value, Worker, WorkerOptions};
let queue = Queue::new("emails", ConnectionOptions::default());queue.add("welcome", data, JobOptions::default())?;
let worker = Worker::new( "emails", |job| { deliver(job.data()) .map(|_| Value::from(true)) .map_err(|e| ProcessError::retryable(e.to_string())) }, WorkerOptions::default(),);worker.run()?;queue = Bunqueue.queue("emails")
{:ok, _job} = Bunqueue.Queue.add(queue, "welcome", %{to: "a@b.co"})
worker = Bunqueue.Worker.new("emails", fn job -> send_email(job.data) {:ok, %{sent: true}} end, concurrency: 5)
Bunqueue.Worker.run(worker)Four tools, one file.
Everything a production queue needs, in the same process and the same SQLite file. Adopt one piece or all of them, each stands on its own.
Queue & Worker
BullMQ-familiar API for adding and processing jobs, end to end TypeScript.
await queue.add('welcome', data, { priority: 5 })Cron & schedulers
Repeatable jobs with cron patterns, intervals and timezones, persisted, never lost on restart.
await app.cron('daily', '0 9 * * *', data)Workflow engine
Multi-step orchestration with automatic rollback when a step fails.
new Workflow('order').step(...).parallel(...)Operations
Failed jobs are kept and retryable, metrics are scrapable, backups are scheduled.
bunx bunqueue-dashboard
Verified, secured, observable.
Tested on every runtime
- 110 e2e scenarios each on Node.js, Deno, Bun
- 16 scenarios inside workerd
- 100 in Python · 33 in PHP · 32 in Go
- every public SDK method covered
Hardened by default
- token auth across TCP and HTTP
- native TLS or Unix domain sockets
- webhook SSRF validation built in
- security model & hardening guide
Observable in production
- Prometheus metrics endpoint
- health and readiness probes
- webhooks, SSE and WebSocket events
- S3 backups for disaster recovery
The questions everyone asks.
Can SQLite handle a production queue?
In WAL mode, yes: reads and writes overlap and the write path is one batched transaction. Measured at 630K jobs/sec bulk push embedded and 90K over TCP, numbers and methodology here.
What happens on a crash?
Jobs live in SQLite, so a restart recovers waiting, delayed and active jobs. A worker that dies mid-job is caught by stall detection and its jobs are requeued. Jobs that keep failing land in the dead letter queue, kept and retryable.
Locks? “database is locked”?
Not in the supported topology: exactly one process opens the file, embedded in your app or as the server, and any number of workers connect over TCP. Two processes on one file is the single thing the docs tell you not to do.
More answers in the FAQ: deduplication, ordering, backups, memory, Postgres.
Start in under a minute.
One install, ten lines, zero infrastructure. On the runtime you already use.
If bunqueue removes a Redis box from your stack, a star on GitHub helps other teams find it.