bunqueue.
One file. Many brokers.
bunqueue is zero-infrastructure background work by default: priorities, retries, cron, a dead letter queue, a durable workflow engine, and a native MCP server. Use one process in memory or with one SQLite file, or opt into PostgreSQL 15–18 when several brokers must share one authoritative queue; 18.6 is recommended. No Redis required. The queue speaks to six languages; the workflow engine runs in-process on Bun.
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:latestgit clone --depth 1 https://github.com/egeominotti/bunqueue.gitcd bunqueuePOSTGRES_PASSWORD='replace-me' \ BUNQUEUE_POSTGRES_URL='postgres://bunqueue:replace-me@postgres:5432/bunqueue' \ docker compose -f docker-compose.postgres.yml up --build -d2 · 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 · embedded or standalone · protocol-conformant Queue/Worker/Flow core across 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
Not just a queue.
A queue is where bunqueue starts, not where it stops. Use memory by default, configure one SQLite file for local persistence, or choose an authoritative PostgreSQL database for a broker fleet. The server also provides cron, flows, a dead letter queue, shared operations, and a native MCP server for AI agents. Every official client shares the protocol-conformant Queue/Worker/Flow core; language-specific capabilities are listed in the SDK matrix. The workflow engine and its agent integrations are in-process Bun APIs.
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 across restarts when SQLite or PostgreSQL storage is configured.
await app.cron('daily', '0 9 * * *', data)Workflow engine
Multi-step orchestration with automatic rollback when a step fails. In-process, Bun only.
new Workflow('order').step(...).parallel(...)Operations
Failed jobs are kept and retryable, metrics are scrapable, backups are scheduled.
bunx bunqueue-dashboard
AI agents
A native MCP server, usable from any language, plus durable agent runs on Bun that survive a restart and roll back the tools a model called.
bunx --package=bunqueue bunqueue-mcp
Polyglot & edge
One server, six official client languages, and a store-and-forward mode that drains edge jobs to a central instance.
queue.forward({ to: { host: 'central' } })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()familiar 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 --package=bunqueue 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(). Use —data-path for a single SQLite-backed server, or BUNQUEUE_POSTGRES_URL when several servers must share one queue. Full quickstart · Storage guide · SDK guide
Same job, three fewer boxes.
BullMQ is excellent software that requires Redis. bunqueue removes that requirement: run the queue in your process and persist it to one SQLite file, or reuse PostgreSQL when you need several active brokers. Coming from Celery, Sidekiq or asynq instead? The public queue contract stays familiar while Redis remains optional infrastructure you do not need.
Running BullMQ
your app bullmq client
redis server provision · secure · monitor
redis persistence AOF / RDB tuning
redis upgrades versions · memory limits
4 moving partsone of them stateful, on call
Running bunqueue
your app bunqueue / bunqueue-client
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.
SQLite keeps the smallest topology; PostgreSQL 15–18 adds a multi-broker topology, with 18.6 recommended. Each has a clear operational boundary. Read this before you adopt.
Storage owns availability
SQLite is one broker with snapshot restore. PostgreSQL mode supports several brokers, while database HA, routing and PITR remain yours to operate. Neither mode is a multi-region consensus layer. See the storage contract.
SQLite lets you choose
SQLite batches non-durable writes for up to 10ms; a process crash inside that window can lose them. Jobs that cannot tolerate bunqueue’s buffer take { durable: true } and commit before add() returns. PostgreSQL admissions are transactional and do not use this buffer; machine/power-loss durability remains a storage-operator concern.
Workers and brokers can scale
Any number of workers connect over TCP. The SQLite server stays single; PostgreSQL 15–18 coordinates multiple broker processes through transactional claims and fenced leases. Edge queues can still drain 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
Start in one file. Scale into shared SQL.
Choose the operational boundary that fits today. Storage selection changes the deployment topology, not the client API or job lifecycle.
SQLite · one broker
Point dataPath at bunq.db. Jobs, schedules, results and the DLQ live in one inspectable file, with no external service and built-in S3 snapshots.
PostgreSQL 15–18
Several standalone servers share transactional claims, fenced leases, durable events, limits, cron, workers, job-state/lifecycle metrics, flows and DLQ state. PostgreSQL 18.6 is recommended.
No ORM. No Redis.
The PostgreSQL path uses Bun’s built-in SQL pool and prepared tagged templates directly. SQLite keeps its synchronous bun:sqlite hot path.
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)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 persistence is batched. A native repeated-process campaign measured 186K jobs/sec public on-disk Embedded addBulk and 159K TCP PUSHB; the separate 729K result is an internal in-memory batch path. Numbers, distributions and methodology here.
What happens on a crash?
With SQLite or PostgreSQL persistence configured, a restart recovers waiting, delayed and active work that was durably admitted. A worker that dies mid-job is caught by stall detection; PostgreSQL mode additionally fences the old lease before another broker recovers it. Repeated failures land in the retryable dead letter queue.
Can I run multiple brokers?
Yes, with PostgreSQL 15–18. Independent servers claim jobs with FOR UPDATE SKIP LOCKED, coordinate through database-clock leases and replay missed notifications from a durable event journal. Do not point multiple processes at one SQLite file.
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.