- Docs
- Worker
- SandboxedWorker
Handlers in their own thread.
CPU-bound work starves an event loop. SandboxedWorker moves the handler into a worker thread so the main thread can keep heartbeating, using the same experimental worker-pool implementation on Bun, Node.js, and Deno.
SandboxedWorker
Section titled “SandboxedWorker”SandboxedWorker runs a processor module in Bun Workers or portable worker threads on Node.js and Deno. The queue and
heartbeat loop stay in the parent thread. A per-job timeout terminates a stuck
thread, and a crashed thread is restarted only while autoRestart is enabled
and its restart budget remains; otherwise it is retired (see
Crashed threads).
This is execution separation, not a security sandbox. Threads share the same OS process and authority; do not use it to run untrusted code, and do not assume an out-of-memory failure is contained to one thread.
Availability
Section titled “Availability”import { SandboxedWorker } from 'bunqueue/client';
const worker = new SandboxedWorker('cpu-intensive', { processor: './processor.ts', // Path to processor file concurrency: 4, // 4 parallel worker threads timeout: 60000, // Per-job timeout (default: 30000, 0 = disabled) maxMemory: 256, // compatibility hint; <= 64 enables smol mode});
await worker.start();import { SandboxedWorker } from 'bunqueue-client';
const worker = new SandboxedWorker('cpu-intensive', { processor: './processor.js', // Compiled ESM processor module connection: { host: '127.0.0.1', port: 6789 }, concurrency: 4, timeout: 60_000,});await worker.start();Node.js and Deno use the portable worker-thread adapter. The processor must be executable by the host runtime; compile TypeScript to JavaScript when needed. Cloudflare Workers cannot create this local thread pool.
The Python SDK does not export SandboxedWorker. Delegate the calculation to a
ProcessPoolExecutor; the normal Worker retains the lease and heartbeat loop:
pool = ProcessPoolExecutor(max_workers=4)worker = Worker("cpu-intensive", lambda job: pool.submit(run_cpu, job.data).result(), concurrency=4, heartbeat_interval_s=10.0, lock_ttl_ms=60_000)The PHP SDK does not export SandboxedWorker. Its Worker is sequential, so use
a supervised child process/service and renew the lease during long waits:
$worker = new Worker('cpu-intensive', function (Bunqueue\Job $job) { $job->extendLock(60000); return runInChildProcess($job->data());}, ['lockTtlMs' => 60000]);The Go SDK does not export SandboxedWorker. Processors already run in a
bounded goroutine pool and the heartbeat loop is separate:
worker := bunqueue.NewWorker("cpu-intensive", processor, bunqueue.WorkerOptions{ Concurrency: 4, LockTtlMs: 60_000, HeartbeatIntervalS: 10,})The Rust SDK does not export SandboxedWorker. Its standard Worker runs
processors on worker threads and heartbeats independently:
let worker = Worker::new("cpu-intensive", processor, WorkerOptions { concurrency: 4, lock_ttl_ms: 60_000, heartbeat_interval: Some(Duration::from_secs(10)), ..Default::default()});The Elixir SDK does not export SandboxedWorker. The standard Worker runs each
handler in a Task and heartbeats from another process; isolate blocking NIFs in
a dirty scheduler or external port:
worker = Bunqueue.Worker.new("cpu-intensive", handler, concurrency: 4, lock_ttl: 60_000, heartbeat_interval: 10_000)The remaining API is shared by both TypeScript packages. A local thread pool requires Bun, Node.js, or Deno; it is unavailable inside Cloudflare Workers.
Processor module
Section titled “Processor module”Processor file (processor.ts):
export default async (job: { id: string; data: any; queue: string; attempts: number; parentId?: string; progress: (value: number | object) => void; log: (message: string) => void; fail: (error: string | Error) => void;}) => { job.progress(50); const result = await heavyComputation(job.data); job.progress(100); return result;};job.progress() is stored as updateProgress stores it on any job: a number as-is
(NaN is 0), a BullMQ-style object as progress 0 with the object’s JSON as the
progress message, a numeric string, boolean or null as its number ('50' is 50), and
other text as progress 0 with the text as the message, as in 2.9.10. The progress
event carries the value as passed. A progress update that still fails (for example the
connection drops) is reported as an error with context: 'progress' and jobId; the
job keeps running.
To connect to a remote server instead of running embedded, pass a connection
option (host, port, token); otherwise the shared embedded manager is used.
Lifecycle and local stats
Section titled “Lifecycle and local stats”await worker.start();worker.isRunning();const stats = worker.getStats(); // { total, busy, idle, recycled, restarts }await worker.stop(); // Graceful (waits for busy workers)await worker.stop(true); // ForcegetStats() reports pool bookkeeping in the current process. It is not a broker
metrics snapshot. recycled counts every slot without a running thread: threads
recycled while idle and threads retired after a crash. A thread still loading its
processor counts as idle.
stop() is idempotent. A second call, or one made while a stop is in progress,
releases nothing again and waits for the same shutdown; stop(true) cuts short a
graceful stop that is still draining. closed fires once per run, whether the pool
stopped through stop() or through idleTimeout. A stop() always wins: it cancels
the autoStart watch, a queue check still in flight, and a restart that is still
starting threads. start() after a stop (or while one finishes) starts a fresh pool
and takes its TCP connection back. In TCP mode the worker holds one reference on
the shared connection pool from construction until it stops (the autoStart watch
keeps it to poll the queue), so stopping never closes connections other clients
still use. The watch waits autoStartPollMs between queue checks and sends one
queue-count request at a time.
A failed start() rejects and leaves nothing running: the threads it started, its
wrapper file and its TCP pool reference are released, and a later start() works.
When the autoStart watch restarts the pool and that start fails, the worker emits
error with context: 'restart' and consecutiveErrors, keeps watching, and retries
after autoStartPollMs × 2^failures, at most max(autoStartPollMs, 30 s).
Thread startup
Section titled “Thread startup”A thread receives jobs only once its processor module has loaded, whether it is
started by start(), restarted after a crash, or respawned after idle recycling. A
processor with slow top-level work (a top-level await, a large import) therefore
never loses a job: the job waits in the queue until a loaded thread is free.
start() waits up to 5 s for its threads to load; a thread that takes longer keeps
loading and gets its first job once it is ready. A processor that fails to load
fails start(); a restarted thread whose processor fails to load counts as a
crash (see Crashed threads) and is given no job.
Pull errors
Section titled “Pull errors”A failed pull never stops the pool, as with Worker. A TCP error, a permanent broker
refusal or a thread that fails to respawn is retried after 100 ms, doubling up to 30 s;
the next answered pull, even an empty one, resets the backoff, and stop() ends the
wait at once. A temporary refusal counts as an empty pull and is followed by the next
pull after pollInterval. The failure is reported as error with queue, consecutiveErrors and
context: 'pull' (or 'spawn'). A refused PULL is a PullRefusedError (reason,
transient), never an empty queue.
- Transient refusals (
Rate limit exceeded, a broker lock timeout such asWrite lock acquisition timed out,Internal server error) count as an empty pull (they also count towardidleTimeout) and are not reported, even to anerrorlistener, as in earlier releases. Other transient failures (a lock timeout thrown by an embedded pull, a timeout, a lost connection) and respawn failures reach only an attachederrorlistener. - Permanent pull failures, such as a refused queue name or a missing or wrong auth
token, reach an attached
errorlistener. Without one they are written to the console instead (one line naming the queue and the reason, at most once a minute), as forWorker: a refusal never ends the process, and the pool resumes as soon as the cause is fixed.
A job pulled for a thread that then fails to respawn is failed with
Dispatch failed: <reason> rather than left holding its lease.
Crashed threads
Section titled “Crashed threads”A thread dies when its processor throws outside the job’s promise (for example
from a timer), leaves a promise rejection unhandled, or calls process.exit(). A
per-job timeout also terminates its thread, and a restarted thread whose processor
module fails to load counts as a crash too. In every case:
- the job that was running fails at once with
Worker crashed: <reason>(the job is retried according to itsattempts) andfailedis emitted, so the job never keeps its lease until a timeout; - an
errorwithcontext: 'crash'andworkerIndexis emitted (for a timeout, only when the broker applied it); - with
autoRestartand restart budget left, the slot gets a new thread, which receives jobs only once its processor has loaded (see Thread startup). Otherwise the slot is retired: it is never respawned and never receives a job.
While some threads still work, the worker keeps running on them at reduced
capacity. When every slot is retired, it pulls nothing more. It emits error with
context: 'exhausted' (and logs it), then stops as stop() would, autoStart
included, because an automatic restart would only crash again; closed follows. Call
start() to run again with a fresh restart budget once the processor is fixed.
SandboxedWorker options
Section titled “SandboxedWorker options”| Option | Type | Default | Description |
|---|---|---|---|
processor | string | (required) | Path to processor file |
concurrency | number | 1 | Parallel worker threads (one thread each); a fraction rounds up and a value below 1 starts one thread |
maxMemory | number | 256 | Compatibility hint: values <= 64 enable Bun’s smol Worker mode. This implementation does not enforce an MB memory limit |
timeout | number | 30000 | Per-job timeout in ms; 0, a negative value or Infinity disables it |
autoRestart | boolean | true | Auto-restart crashed threads; when false, a crashed thread is retired at once |
maxRestarts | number | 10 | Restart budget per thread; the counter increments before the check, so 10 allows 9 actual restarts. A thread past it is retired; the worker stops when every thread is |
pollInterval | number | 10 | Sleep in ms when no idle thread is available, at least 1; job pulls themselves use a fixed 1000 ms broker long-poll |
heartbeatInterval | number | 5000 (embedded) / 10000 (TCP) | Heartbeat for stall detection and lock renewal; non-positive disables it, otherwise at least 1 |
idleTimeout | number | 0 | Stop the pool after this many idle ms; 0, a negative value or Infinity disables it |
idleRecycleMs | number | 30000 | Recycle idle threads after this many ms; 0, a negative value or Infinity disables it |
autoStart | boolean | false | Restart the pool when new jobs arrive after an idle shutdown |
autoStartPollMs | number | 5000 | Wait in ms between queue checks while idle-stopped, at least 1 |
connection | ConnectionOptions | - | TCP connection (omit for embedded) |
The constructor validates concurrency and every duration option. A numeric string
('4') is read as that number. concurrency is rounded up, and a value below 1
starts one thread (0 → 1, 2.5 → 3). timeout, idleTimeout and idleRecycleMs
are disabled by 0, a negative value, NaN or Infinity. Anything else the pool could
not run throws, naming the option and the value it received: a TypeError for a
non-number, and a RangeError for a concurrency of NaN or Infinity (endless
thread spawning), or a pollInterval, autoStartPollMs or positive heartbeatInterval
that is NaN, infinite or below 1 ms. For example:
SandboxedWorker: pollInterval must be a finite number of milliseconds >= 1 (got NaN).
Long durations are honoured exactly, including ones above 2^31 - 1 ms (about 24.8
days), which the runtime’s own timers would otherwise fire after about 1 ms.
SandboxedWorker emits eight local events: ready, active, completed,
failed, progress, log, error, and closed. It does not emit
stalled, drained, or cancelled. completed/failed fire only after the
broker confirms the ACK/FAIL as applied; if the broker reports the job as
already finalized no event fires, and an ACK/FAIL transport error emits error
instead.
Worker vs SandboxedWorker
Section titled “Worker vs SandboxedWorker”| Comparison | Worker | SandboxedWorker |
|---|---|---|
| Production ready | ✅ Stable | ⚠️ Experimental bunqueue implementation |
| I/O-bound tasks (HTTP, DB, APIs) | ✅ Best choice | Overkill |
| CPU-intensive tasks | ⚠️ Blocks event loop | ✅ Runs in separate thread |
| Untrusted code | ❌ Not isolated | ❌ Thread separation is not a security boundary |
| Per-thread memory limit | ❌ | ❌ maxMemory does not enforce one |
| Events | 11 events | 8 events |
| Concurrency, retries, heartbeats | ✅ | ✅ Supported through a separate implementation |
Most workloads are I/O-bound (API calls, database queries, file operations); for
those, Worker is the right choice. For CPU-heavy work, see
CPU-Intensive Workers for the supported
offloading and lease-sizing patterns.
Where to go next
Section titled “Where to go next”| Guide | What it covers |
|---|---|
| Worker | Create a worker and process your first job |
| Worker Concurrency and Batch Pulling | Run jobs in parallel and pull them in batches |
| The Job Object Inside a Worker Processor | Everything the processor receives and can do |
| Worker Events | completed, failed, stalled and the rest |
| Worker Error Handling, Retries and Backoff | Retries, backoff, timeouts and giving up |
| Worker Lifecycle | Pause, resume and shut down without losing work |
| Heartbeats, Stall Detection and Lock Ownership | Heartbeats, stall recovery and lock ownership |
| WorkerOptions Reference | Every WorkerOptions field, with defaults |