Skip to content
Get started
Get started
Stall Detection: Auto-Recover Unresponsive Jobs
View Markdown
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+ of heartbeat silence marks a job stalled (confirmed over two 5s sweeps, so ~35–40s in practice)3 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. Detection is poll-driven and two-phase: a job must exceed stallInterval in two consecutive 5-second sweeps before it is marked stalled, so the earliest detection is roughly stallInterval plus one to two sweeps (~35–40s at the defaults).

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 or PostgreSQL persistence enabled, a custom stall policy and every job’s cumulative stall count survive process and broker 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 path depends on how the stall was detected. Heartbeat-stall recovery re-queues the job with its stall count incremented and runAt pushed out by the job’s exponential backoff, without waking blocked pullers, so pickup waits for the backoff plus the next poll. Lock-expiry recovery re-queues without backoff and notifies waiting workers immediately.
  2. DLQ: the job becomes terminal when either its cumulative stall count reaches maxStalls or the interrupted delivery consumes its final normal attempts slot. The attempts check wins ties: since each stall also consumes an attempt, with the defaults (maxStalls: 3, attempts: 3) a repeatedly stalling job lands in the DLQ as max_attempts_exceeded. The stalled classification appears only when maxStalls is lower than the job’s remaining attempts budget.

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 shared TypeScript Worker also emits stalled in embedded and TCP modes. Its TCP path uses the same dedicated authenticated broker subscription as QueueEvents and re-subscribes after reconnect. The other language 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' });

Both TypeScript packages expose authoritative getDlqStatsAsync() and getDlqAsync({ reason }) over TCP. The other SDKs list raw DLQ jobs with getDlq(count?) / get_dlq() / GetDlq(count) and do not expose the entry’s failure-reason metadata through those helpers.

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 }).