Skip to content
Get started
Get started
CPU-Intensive Workers: Preserve Heartbeats and Locks
guide · cpu-intensive-workers

CPU-intensive work, off the control path.

A long, non-yielding handler can starve the code that renews its lease. Keep the broker safety mechanisms on and move computation to a thread, process, or service that cannot block them.

The important clock is not total job duration. A job can run for hours while heartbeats keep arriving. The dangerous interval is the longest period during which the worker cannot run its heartbeat or lock-renewal path.

When that interval exceeds the queue’s stallInterval or the job lock’s TTL, bunqueue may recover and redeliver the job. A stale processor can still finish its local computation, but its expired token cannot safely acknowledge the new processing instance. CPU-heavy handlers must therefore remain idempotent.

Keep the queue processor responsive and hand CPU work to the runtime’s native isolation mechanism. runCpuTaskOffThread, heavy_computation, and similar names below stand for application code, not bunqueue helpers.

import { Worker } from 'bunqueue/client';
const worker = new Worker('heavy', async (job) => {
// Back this helper with a Bun Worker or a supervised child-process pool.
return runCpuTaskOffThread(job.data);
}, {
connection: { host: '127.0.0.1', port: 6789 },
concurrency: 4,
heartbeatInterval: 10_000,
lockDuration: 120_000,
});

The regular Worker owns the TCP connection and lease; the CPU pool does only the computation. The experimental built-in alternative is SandboxedWorker.

For computation you control, small cooperative yields can be sufficient. This Bun example yields every 500 iterations so timers and TCP I/O can run:

async function findNthPrime(n: number): Promise<number> {
let count = 0;
let candidate = 1;
let operations = 0;
while (count < n) {
candidate++;
if (isPrime(candidate)) count++;
if (++operations % 500 === 0) await Bun.sleep(0);
}
return candidate;
}

Choose the yield frequency by measuring the longest uninterrupted block, not by iteration count alone. A large iteration can itself take longer than the lease window.

Offloading is the primary fix. If a bounded segment can still delay ownership traffic, set both of these above its worst-case duration:

  • the Worker’s job-lock TTL (lockDuration in Bun, lockTtlMs or its language-specific equivalent in network SDKs);
  • the queue’s server-side stallInterval.

Keep the heartbeat interval comfortably below both. The queue-level policy is shared by every language; configure it as shown in Stall Detection.

SettingScopeWhat happens when it expires
heartbeatInterval / SDK equivalentWorkerHow often the current lease is renewed; 0 disables renewal where supported
lockDuration / lockTtlMsJob leaseThe ownership token becomes eligible for expiry if it is not renewed
stallIntervalQueue policyA job with no recent heartbeat becomes a stall candidate and may be retried or sent to the DLQ
commandTimeoutTCP commandAn unanswered protocol request rejects; repeated command timeouts can reconnect the client
pingIntervalTCP connectionControls active health probes; it is unrelated to a job’s allowed run time
job timeoutJob execution policyThe worker/broker failure path treats the processing attempt as timed out
  • Make the handler idempotent; bunqueue delivery is at least once.
  • Keep automatic heartbeats and lock renewal enabled.
  • Offload non-yielding CPU work from the connection/control thread.
  • Bound each isolated task with an application timeout and supervise its process.
  • Make lock and stall windows longer than the measured worst uninterrupted block.
  • Test process death as well as successful completion; recovery behavior matters more than the happy-path benchmark.