Client SDKs for Node.js, Deno, Python and Cloudflare Workers
One protocol, every runtime.
Official client SDKs for Node.js, Deno, Bun, Python and Cloudflare Workers. Each one speaks the native TCP protocol, msgpack and pipelined, with full feature parity with the built-in Bun client.
The design is simple: the server owns every queue semantic, retries with backoff, priorities, scheduling, stall detection, the dead letter queue. Your applications only add jobs and process them. A Next.js API written in TypeScript can enqueue work that a Python service consumes, both against the same queue, with no shared runtime and no translation layer.
Supported platforms
Section titled “Supported platforms”| Platform | Package | Current | Distribution |
|---|---|---|---|
| Node.js ≥ 20, Bun, Deno ≥ 2, Cloudflare Workers | bunqueue-client | 0.1.6 | npm · source |
| Python ≥ 3.9 | bunqueue-client | 0.1.1 | PyPI upcoming, installable from the repository today |
| Bun, embedded in process, no server | bunqueue | — | Quick Start |
Both SDKs are versioned independently from the server and follow semantic
versioning. The wire protocol is versioned too (protocolVersion: 1,
negotiated via Hello), so a client keeps working across server upgrades.
Every claim on this page is verified against a real bunqueue server, on every supported platform: 100 end-to-end scenarios each on Node.js, Deno and Bun, 16 inside workerd (the actual Cloudflare Workers runtime), and 66 in Python, enforced in CI on every change (runtime matrix: Bun, Node and Deno for TypeScript; Python 3.10 and 3.12), with npm releases published with build provenance and gated on the full e2e suite.
Getting started
Section titled “Getting started”1. Run the server
Section titled “1. Run the server”The server is the only component that requires Bun.
bunx bunqueue start# or, with Docker and persistent data:# docker run -d -p 6789:6789 -p 6790:6790 -v bunqueue-data:/app/data ghcr.io/egeominotti/bunqueue:latestPort 6789 serves the TCP protocol used by the SDKs, port 6790 serves the
HTTP API with /health and /metrics. Additional deployment options are
covered in Running the Server.
2. Install the client
Section titled “2. Install the client”npm install bunqueue-clientbun add bunqueue-clientdeno add npm:bunqueue-client# PyPI release upcoming; install from the repository today:pip install "git+https://github.com/egeominotti/bunqueue.git#subdirectory=sdk/python"3. Produce jobs
Section titled “3. Produce jobs”import { Queue } from 'bunqueue-client';
const queue = new Queue('emails');
await queue.add('welcome', { to: 'user@example.com' });from bunqueue import Queue
queue = Queue("emails")
queue.add("welcome", {"to": "user@example.com"})4. Process jobs
Section titled “4. Process jobs”import { Worker } from 'bunqueue-client';
const worker = new Worker('emails', async (job) => { await sendEmail(job.data.to); return { sent: true };});
worker.on('completed', (job) => console.log('done:', job.id));worker.on('error', (err) => console.error(err)); // always attach (see Worker semantics)from bunqueue import Worker
def process(job): send_email(job.data["to"]) return {"sent": True}
Worker("emails", process, concurrency=10).run()Run either file with the runtime you already use:
node --experimental-strip-types app.ts # Node 22 or laterbun app.ts # Bundeno run -A app.ts # Deno 2 or laterpython app.py # PythonProducer and worker are usually separate services, often in different
languages, a Next.js API adds jobs, a Python service processes them,
against the same queue and the same protocol. Constructors default to
host: 'localhost' and port: 6789, so no options are needed for a local
setup.
Protocol and architecture
Section titled “Protocol and architecture”Understanding four facts about the transport explains most SDK behavior:
- Framing, every message is a 4-byte big-endian length prefix followed by a standard msgpack map. Maximum frame size is 64 MB; maximum job payload is 10 MB.
- Pipelining, every request carries a
reqIdthe server echoes back, so many commands are in flight on one socket concurrently. A single connection is usually all a service needs. - Authentication-first, when a token is configured,
Authis guaranteed to be the first frame on every (re)connection. The TypeScript client enforces this through synchronous write ordering; the Python client through a connection lock (safe under free-threaded concurrency). - The job name travels inside
data,add('welcome', {to})is encoded asdata: { name: 'welcome', to }. Non-object payloads are wrapped as{ name, payload }.
Connection options
Section titled “Connection options”const queue = new Queue('emails', { host: 'queue.example.com', port: 6789, token: process.env.BUNQUEUE_TOKEN, tls: true, // or { caFile } or { rejectUnauthorized: false } commandTimeoutMs: 10000, // default maxInFlight: 10_000, // backpressure bound; 0 = unbounded (default) poolSize: 4, // producer-side connection pool; default 1});queue = Queue( "emails", host="queue.example.com", port=6789, token=os.environ["BUNQUEUE_TOKEN"], tls=True, # or {"ca_file": "./ca.pem"} or an ssl.SSLContext command_timeout=10.0, # default)The TCP connect timeout is internal in both SDKs (5 seconds) and is not configurable through the constructor; the command timeout above governs how long each in-flight command waits for a response.
Connection resilience
Section titled “Connection resilience”Both SDKs implement the same production-grade recovery model, verified by dedicated failure-injection tests:
| Mechanism | Behavior |
|---|---|
| Lazy reconnect | A lost connection reconnects transparently on the next call; workers re-register automatically after every reconnect (generation counter) |
| Reconnect backoff | Exponential 500 ms → 5 s with a fast-fail window, so a producer calling add() against a downed server does not pay the full connect timeout on every call |
| TCP keepalive | Enabled at ~15 s idle: a half-open link (cloud NAT / load-balancer idle drop with no FIN/RST) is detected in seconds instead of the OS-level ~15 minutes |
| Timeout-driven teardown | Three consecutive command timeouts tear the socket down so the next call reconnects, generation-guarded, so a stale timeout can never abort a freshly reconnected socket |
| Backpressure (TypeScript) | With maxInFlight set, callers park until an in-flight slot frees instead of growing memory without bound under a fast producer / slow server |
| Auth ordering | No command can race ahead of the Auth frame on a reconnect, a dropped ACK caused by that race would otherwise resurface as a silent duplicate execution |
Producing jobs
Section titled “Producing jobs”Job options
Section titled “Job options”Every option is validated server-side; identical semantics in both SDKs
(Python uses snake_case: attempts, job_id, remove_on_complete, …).
| Option | Type | Default | Notes |
|---|---|---|---|
priority | number | 0 | Higher runs sooner; −1 000 000 … 1 000 000 |
delay | ms | 0 | Up to 1 year |
attempts | number | 3 | Max attempts including the first; up to 1000 |
backoff | number | { type, delay } | 1000 | type: 'fixed' or 'exponential'; delay up to 1 day |
ttl | ms | — | Expires the job if not processed in time |
timeout | ms | — | Per-job processing timeout, up to 1 day |
jobId | string | — | Custom id; idempotent, re-adding an unfinished id is a no-op |
deduplication | { id, ttl?, extend?, replace? } | — | Dedup window keyed on id |
debounce | { id, ttl? } | — | Collapses bursts to the last job |
dependsOn | string[] | — | Job ids that must complete first |
parentId / childrenIds | string / string[] | — | Flow relationships (usually set via FlowProducer) |
tags / groupId | string[] / string | — | Metadata; groupId also scopes group rate limits |
lifo | boolean | false | Orders only among LIFO jobs; mixed queues stay FIFO |
removeOnComplete / removeOnFail | boolean | false | Drop the job record at the terminal state |
durable | boolean | false | Bypass the server write buffer, fsync before the ACK returns |
repeat | { every } or { pattern, tz? } + limit? | — | Repeatable jobs (see Cron) |
stallTimeout | ms | — | Per-job stall detection override |
stackTraceLimit / keepLogs / sizeLimit | number | — | Failure stack cap, retained log lines, payload cap |
await queue.add('report', data, { priority: 10, delay: 5000, attempts: 5 });await queue.add('charge', payment, { jobId: `order-${orderId}`, durable: true });Idempotency and bulk
Section titled “Idempotency and bulk”jobId makes add idempotent: re-adding an id whose job is still
unfinished (waiting, active, waiting-children) returns the existing job
instead of creating a duplicate. This holds for addBulk too, each bulk
entry’s jobId is preserved on the wire, so an idempotent batch ingest can
be re-run safely after a crash:
await queue.addBulk( orders.map((o) => ({ name: 'ingest', data: o, opts: { jobId: `order-${o.id}` } })));queue.add_bulk([ {"name": "ingest", "data": o, "job_id": f"order-{o['id']}"} for o in orders])Producer throughput
Section titled “Producer throughput”For high-volume producers the TypeScript SDK can fan commands across a round-robin connection pool, one line, no API change:
const queue = new Queue('ingest', { poolSize: 4 });The pool is producer-side only: workers intentionally keep a single connection, because job leases (lock tokens) and worker registration are per-connection server state.
Processing jobs
Section titled “Processing jobs”Worker options
Section titled “Worker options”| Option | Default | Notes |
|---|---|---|
concurrency | 4 | Jobs processed in parallel |
batchSize | 10 | Jobs fetched per PULLB, capped by free slots |
pollTimeoutMs | 5000 | Server-side long-poll; max 30 000 |
lockTtlMs | 30 000 | Job lease TTL |
heartbeatIntervalS | 10 | Worker + per-job lock heartbeats |
ackBatch | off | Opt-in ACK batching (below) |
autorun | true | Start the pull loop at construction |
Lease model
Section titled “Lease model”A pulled job carries a lock token. The worker heartbeats every active
job’s lock on the heartbeat interval, so a job that legitimately runs
longer than the lock TTL survives. If the worker dies, the lease expires
and the server requeues the job (or moves it to the DLQ once maxStalls
is exceeded), at-least-once delivery, so make handlers idempotent.
Failures
Section titled “Failures”import { UnrecoverableError } from 'bunqueue-client';
const worker = new Worker('emails', async (job) => { if (!isValid(job.data)) { throw new UnrecoverableError('malformed payload'); // skip retries → DLQ } return await send(job.data);});A thrown error fails the job with its message and the leading stack lines
(persisted server-side, capped by stackTraceLimit); the server applies
the retry/backoff policy and eventually the dead letter queue.
UnrecoverableError bypasses retries entirely.
Worker events
Section titled “Worker events”ready, active, completed, failed, progress, drained,
cancelled, error, closed.
Always attach an error listener. Per Node EventEmitter semantics an
unhandled error event throws. Both workers free each job’s concurrency
slot before emitting, and the Python worker additionally swallows listener
exceptions, so a throwing listener cannot leak a slot or degrade throughput
in either SDK. The error itself is yours to observe.
ACK batching (high volume)
Section titled “ACK batching (high volume)”Opt-in: coalesce completed-job acknowledgements into ACKB round-trips.
const worker = new Worker('ingest', process, { concurrency: 32, ackBatch: { enabled: true, maxSize: 50, maxDelayMs: 5 },});worker = Worker( "ingest", process, concurrency=32, ack_batch={"max_size": 50, "max_delay_ms": 5},)Semantics are strict: a job stays active, its lock still heartbeated,
until the server confirms the batch; the batch is flushed on close();
every job settles exactly once even if an event listener throws. Defaults
are off, so nothing changes unless you enable it.
Graceful shutdown
Section titled “Graceful shutdown”await worker.close(); // stop pulling, flush batched ACKs, drain in-flight jobsawait worker.close(true); // force: skip the in-flight drainworker.close() # stop pulling, wait for in-flight jobs to drainworker.close(timeout=5) # bound the wait; the drain continues in the backgroundPython has no force flag: close(timeout=...) bounds how long the call
waits, and close(timeout=0) detaches immediately while in-flight jobs
finish in the background.
Query, control and operations
Section titled “Query, control and operations”The full management surface is available in every SDK with identical semantics (Python: same methods in snake_case; the rare name that differs is noted inline).
| Area | Methods |
|---|---|
| Query | getJob, getJobByCustomId, getJobs + per-state helpers (getWaiting, getActive, getCompleted, getFailed, getDelayed, getPrioritized, getWaitingChildren), getJobState (Python name: get_state), getResult, getProgress, counts + counts-per-priority, getChildrenValues, job logs |
| Waiting | waitForJob(id, ttlMs), resolves with the result; throws on timeout (CommandTimeoutError) and on a failed job (CommandError), never silently returns undefined |
| Control | pause / resume / isPaused, drain, obliterate, clean, remove, discard, promoteJob(s), retryJob (failed → waiting), updateJobData, updateJobProgress, changeJobPriority, changeJobDelay, moveJobToWait/Delayed/Completed/Failed, extendJobLock |
| DLQ | getDlq, retryDlq(id?), purgeDlq, retryJobs (BullMQ semantics: retries the whole DLQ by default, or pass count to retry only the first N entries, honored by server 2.8.29 and later; Python retry_dlq accepts count= too), DLQ configuration |
| Schedulers | upsertJobScheduler (cron pattern or fixed interval, timezone, skipIfNoWorker, preventOverlap, per-scheduler job options), removeJobScheduler, getJobScheduler(s), addCron, every |
| Admin | rate limiting, global concurrency, stall configuration, webhooks, getStats, getMetrics, listQueues, getWorkers |
Two behaviors worth knowing:
- Not-found is
null/None, never an exception,getJob,getJobByCustomIdandgetJobSchedulermap the server’s not-found response for you. updateJobProgressrequires an active job; the server rejects progress updates on waiting jobs by design.
Pipelines and parent/child trees with automatic ordering, children complete before their parent, results are readable from the parent:
import { FlowProducer } from 'bunqueue-client';
const flow = new FlowProducer();
// Sequential pipelineawait flow.addChain([ { name: 'extract', queueName: 'etl' }, { name: 'transform', queueName: 'etl' }, { name: 'load', queueName: 'etl' },]);
// Tree: parent waits for its childrenconst node = await flow.add({ name: 'assemble', queueName: 'orders', children: [ { name: 'reserve-stock', queueName: 'orders' }, { name: 'charge-card', queueName: 'orders' }, ],});
// Fan-in: N parallel jobs converge into oneawait flow.addBulkThen(parts, { name: 'merge', queueName: 'orders' });Creation is transactional per call: if any push fails, every job already
created by that call is cancelled (rollback). getFlow(id) reconstructs a
tree from a root id, it returns null for a missing root, skips children
that were since removed (removeOnComplete), and is cycle-safe, so it
always returns the surviving partial tree instead of throwing.
Observability
Section titled “Observability”The TypeScript SDK exposes a zero-dependency observability layer: you bring OpenTelemetry, Prometheus or your own logger, the SDK stays silent by default and consumer errors are isolated so a throwing sink can never break the transport.
import { Queue, consoleLogger, type TelemetryEvent } from 'bunqueue-client';
const queue = new Queue('emails', { logger: consoleLogger('info'), // or any { debug, info, warn, error } onTelemetry: (e: TelemetryEvent) => { if (e.type === 'command') histogram.observe({ cmd: e.cmd }, e.durationMs); if (e.type === 'reconnect_scheduled') reconnects.inc(); },});
// Connection is an EventEmitter for imperative lifecycle hooks:queue.connection.on('connect', (i) => log.info('link up', i));queue.connection.on('disconnect', (i) => log.warn('link down', i));queue.connection.on('reconnect_scheduled', (i) => log.warn('retrying', i));TelemetryEvent is a typed union: command (per-command latency and
outcome), command_timeout, connect, disconnect,
reconnect_scheduled, auth, backpressure. For server-side metrics,
scrape the HTTP /metrics endpoint (Prometheus format).
Simple Mode
Section titled “Simple Mode”Bunqueue bundles a Queue and a Worker into a single object, a 1:1 port
of the official client’s Simple Mode. It brings
routes, onion middleware, in-process retry strategies, a circuit breaker,
batch accumulation, event triggers, job TTL, priority aging, cooperative
cancellation, and deduplication or debounce defaults.
import { Bunqueue } from 'bunqueue-client';
const app = new Bunqueue('notifications', { routes: { 'send-email': async (job) => ({ sent: true }), 'send-sms': async (job) => ({ sent: true }), }, concurrency: 10, retry: { maxAttempts: 5, strategy: 'jitter' }, circuitBreaker: { threshold: 5, resetTimeout: 30_000 },});
app.use(async (job, next) => { console.time(job.name); const result = await next(); console.timeEnd(job.name); return result;});
await app.add('send-email', { to: 'alice@example.com' });await app.cron('daily-digest', '0 9 * * *', { to: 'all' });from bunqueue import Bunqueue
app = Bunqueue( "notifications", routes={ "send-email": lambda job: {"sent": True}, "send-sms": lambda job: {"sent": True}, }, concurrency=10, retry={"max_attempts": 5, "strategy": "jitter"}, circuit_breaker={"threshold": 5, "reset_timeout": 30000},)
def timing(job, next_fn): result = next_fn() print(f"{job.name} done") return result
app.use(timing)
app.add("send-email", {"to": "alice@example.com"})app.cron("daily-digest", "0 9 * * *", {"to": "all"})Scheduler job options are honored end to end: the template’s attempts,
backoff, timeout, delay, remove_on_complete / remove_on_fail,
priority and deduplication are applied to every cron-spawned job, in
both SDKs.
One difference from the Bun original: embedded: true is not available,
it requires the in-process Bun runtime. Connect to a server instead; the
option raises a clear error if you try.
Cloudflare Workers
Section titled “Cloudflare Workers”The same client runs inside Workers. Enable Node.js compatibility and add jobs directly from your fetch handlers:
compatibility_flags = ["nodejs_compat"]compatibility_date = "2025-01-01"import { Queue } from 'bunqueue-client';
export default { async fetch(req: Request, env: Env): Promise<Response> { const queue = new Queue('signups', { host: env.BQ_HOST, port: 6789, token: env.BQ_TOKEN, tls: true }); const job = await queue.add('welcome', await req.json()); queue.close(); return Response.json({ queued: job.id }); },};Workers are request-scoped, so there is no long-lived worker loop. Instead: produce from fetch handlers, and consume in batches from a Cron Trigger, pull, process, acknowledge, return. Both patterns (Simple Mode included) are covered by the SDK’s 16-scenario suite that runs inside workerd. Two requirements apply: the server must be reachable from the internet, and TLS needs a publicly trusted certificate.
Security
Section titled “Security”Authentication uses server-side tokens; transport security uses native TLS.
const queue = new Queue('emails', { host: 'queue.example.com', port: 6789, token: process.env.BUNQUEUE_TOKEN, // server started with AUTH_TOKENS=... tls: true, // or { caFile: './ca.pem' } for a custom CA});queue = Queue( "emails", host="queue.example.com", port=6789, token=os.environ["BUNQUEUE_TOKEN"], tls={"ca_file": "./ca.pem"}, # or True for system CAs, or an ssl.SSLContext)Certificate verification is on by default for every TLS connection,
a wrong or missing CA rejects the connection instead of silently
connecting; only an explicit rejectUnauthorized: false (TypeScript) or
{"verify": False} (Python) opts into encryption-only mode for
development. Start the server with AUTH_TOKENS to require
authentication, and with TLS_CERT_FILE plus TLS_KEY_FILE for encrypted
transport. Full hardening guidance lives in the
deployment guide.
Errors and guarantees
Section titled “Errors and guarantees”Both SDKs expose the same typed error hierarchy, so retry logic can branch precisely:
| Error | Meaning |
|---|---|
ConnectionClosedError | Link lost or server unreachable (in-flight commands reject; the next call reconnects) |
CommandTimeoutError | No response within the command timeout, also thrown by waitForJob on timeout |
CommandError | The server answered ok: false (validation error, not-found on write ops, failed job in waitForJob) |
AuthError | Token rejected during the connection handshake |
UnrecoverableError | Thrown by your processor to skip retries and fail terminally |
Delivery is at-least-once: a worker crash after processing but before
the ACK means the job runs again, design handlers to be idempotent (the
jobId option helps on the producing side). Two numeric-precision rules:
- JavaScript numbers are IEEE 754 doubles, exact up to 2⁵³. Pass larger
64-bit identifiers (snowflake ids) as strings; never put
BigIntin job data. - Python integers outside the int32 range are automatically encoded as float64 on the wire (exact up to 2⁵³, safe for millisecond timestamps); the same string rule applies to larger identifiers.
Quality assurance
Section titled “Quality assurance”Every SDK release is validated against a real bunqueue server, spawned fresh for each run, on every supported runtime, locally and in CI on every change:
| Runtime | Verification |
|---|---|
| Node.js | 100 e2e scenarios and 8 integration tests |
| Bun | 100 e2e scenarios and 8 integration tests |
| Deno | 100 e2e scenarios and 8 integration tests, identical test files |
| Cloudflare Workers | 16 scenarios executed inside workerd, full API surface and Simple Mode |
| Python | 66 e2e scenarios and 8 integration tests |
Coverage includes payload limits, unicode integrity, pipelining under concurrency, server crash and restart with automatic reconnection, half-open-link recovery, auth ordering under concurrent commands, ACK-batch error paths (a failing batch settles every job exactly once and leaks no concurrency slot), lock survival for jobs that outlast the lock TTL, and a realistic multi-queue production scenario with zero-loss accounting. npm releases are published with build provenance and gated on the e2e suite.
Browsers are not supported, raw TCP sockets are unavailable there, so route through your own API instead.
Resources
Section titled “Resources”bunqueue-clienton npm, the full step by step README, from server start to production- Changelog (TypeScript SDK) · Changelog (Python SDK)
- Queue API, every job option explained
- Worker, concurrency, events, graceful shutdown
- Simple Mode, everything in one object
- Flows, pipelines, parent and child jobs, fan in
- Cron Jobs, schedules and repeatable jobs
- Dead Letter Queue, what happens when jobs fail
- Deployment guide, Docker, TLS, authentication, monitoring
- SDK sources:
sdk/typescript·sdk/python