Skip to content
Get started
Get started
Stall Detection: Auto-Recover Unresponsive Jobs
guide · stall-detection

Stuck jobs come back.

If a worker crashes or hangs mid-job, the job is not lost. bunqueue notices the silence, retries the job, and parks repeat offenders in the dead letter queue.

30s without a heartbeat marks a job stalled3 stalls before a job moves to the DLQ5s grace period after a job starts

While a worker processes a job it sends periodic heartbeats, small “I’m still alive” signals. If heartbeats stop (crashed process, hung code, dead network), the job is stalled: bunqueue re-queues it for another worker, and after too many stalls moves it to the dead letter queue (DLQ), the holding area for jobs that keep failing.

Stall detection is on by default with sensible defaults. A job may run for hours without stalling as long as automatic heartbeats continue. Tune these thresholds when one uninterrupted work segment can block heartbeats for more than 30 seconds, or when you need a different recovery budget.

import { Queue } from 'bunqueue/client';
const queue = new Queue('my-queue', { embedded: true });
queue.setStallConfig({
enabled: true, // on by default
stallInterval: 30000, // stalled after 30s without a heartbeat
maxStalls: 3, // move to DLQ after 3 stalls
gracePeriod: 5000, // no stall checks in the first 5s of a job
});

The stall policy is server-side state per queue: a policy set from any client (or the HTTP API) governs jobs processed by workers in every language. The stall-config helper ships in the Bun, TypeScript, and Python clients; the PHP, Go, Rust, and Elixir SDKs do not expose one yet.

OptionDefaultDescription
enabledtrueEnable/disable stall detection
stallInterval30000Time (ms) without a heartbeat before a job is stalled
maxStalls3Max stalls before moving to DLQ
gracePeriod5000Initial grace period (ms) after a job starts

On the worker side, heartbeats are automatic:

const worker = new Worker('queue', processor, {
embedded: true,
heartbeatInterval: 10000, // heartbeat every 10 seconds (default)
});

Keep heartbeatInterval well below stallInterval, otherwise healthy jobs get flagged as stalled.

With SQLite persistence enabled, a custom stall policy and every job’s cumulative stall count survive process restarts. A crash consumes one attempts slot and one stallCount slot; reaching either maxAttempts or maxStalls is terminal and moves the job to the DLQ. Repeated crashes therefore cannot reset either retry budget.

A long total runtime is safe with heartbeats. Use a wider stall window when a single processing segment can block the runtime or network long enough to miss the default 30-second window:

// Video processing may take hours
const videoQueue = new Queue('video-processing', { embedded: true });
videoQueue.setStallConfig({
stallInterval: 300000, // 5 minutes
maxStalls: 2,
gracePeriod: 60000,
});
const worker = new Worker('video-processing', async (job) => {
for (const chunk of video.chunks) {
await processChunk(chunk);
await job.updateProgress(chunk.progress); // also counts as a heartbeat
}
}, { embedded: true, heartbeatInterval: 30000 });

Two things reset the stall timer: the worker’s automatic heartbeat (every heartbeatInterval ms) and any job.updateProgress() call. For long jobs without natural progress points, the automatic heartbeat is enough.

  1. Retry: the job goes back to waiting with its stall count incremented, and waiting workers are notified immediately, so it is picked up without delay.
  2. DLQ: the job becomes terminal when either its cumulative stall count reaches maxStalls or the interrupted delivery consumes its final normal attempts slot. A stall-budget terminal is classified as stalled; an attempts-budget terminal is classified as max_attempts_exceeded.

The Bun package can listen through embedded or TCP QueueEvents:

import { QueueEvents } from 'bunqueue/client';
const events = new QueueEvents('my-queue', {
embedded: false,
connection: { host: '127.0.0.1', port: 6789 },
});
await events.waitUntilReady();
events.on('stalled', ({ jobId }) => {
console.log(`Job ${jobId} stalled`);
});

The Bun Worker also emits stalled in both embedded and TCP modes. Its TCP path uses the same dedicated authenticated broker subscription as QueueEvents and re-subscribes after reconnect. External network SDKs should use SSE or WebSocket for this broker-side event. Stall webhooks are not emitted, so do not register job.stalled as a webhook event. This notification is preserved when an expired lease consumes the final maxStalls or maxAttempts slot: the broker publishes stalled before the terminal failed queue event and moves the job to the DLQ.

const stats = queue.getDlqStats();
console.log('Stalled jobs in DLQ:', stats.byReason.stalled);
const stalledJobs = queue.getDlq({ reason: 'stalled' });

The aggregate getDlqStats() and the reason filter are Bun-client conveniences. The external SDKs list DLQ entries with getDlq(count?) / get_dlq() / GetDlq(count) and each entry carries a reason field ('stalled' for stall evictions) you can filter on client-side.

SandboxedWorker also sends heartbeats automatically in both modes; in embedded mode heartbeatInterval defaults to 5000 ms. If its jobs run longer than stallInterval, either raise stallInterval, call progress() periodically, or disable stall detection with queue.setStallConfig({ enabled: false }).