# Heartbeats, Stall Detection and Lock Ownership

How a bunqueue job stays alive while it runs: heartbeats, stall recovery when a worker dies, and the lock that stops two workers processing the same job.

Canonical: https://bunqueue.dev/guide/worker/stalls/

---

import { Tabs, TabItem } from '@astrojs/starlight/components';

<div class="bq-wrap bq-hero">
  <span class="bq-eyebrow">guide · worker</span>
  <h1 class="bq-hero-h1 bq-bench-h1">Proving the worker is <em>still alive.</em></h1>
  <p class="bq-hero-sub">A crashed worker cannot tell anyone. Heartbeats let the queue notice, and lease tokens ensure only the current owner can settle a recovered job.</p>
</div>

## Heartbeats and stall detection

While a job is processing, the worker automatically pings the queue ("I'm still working on this"). That ping is the heartbeat. If a job stops receiving heartbeats, for example because the worker crashed, the queue marks it stalled and recovers it, so no job is silently lost.

<Tabs syncKey="lang">
<TabItem label="Bun">

```typescript
const worker = new Worker('queue', processor, {
  embedded: true,
  heartbeatInterval: 5000, // Ping every 5 seconds (ms)
});
```

</TabItem>
<TabItem label="Node.js / Deno">

```typescript
const worker = new Worker('queue', processor, {
  embedded: false,
  heartbeatInterval: 5000, // Ping every 5 seconds (ms)
});
```

</TabItem>
<TabItem label="Python">

```python
worker = Worker("queue", process, heartbeat_interval_s=5.0)  # 0 disables
```

</TabItem>
<TabItem label="PHP">

```php
$worker = new Worker('queue', $processor, [
    'heartbeatIntervalS' => 5.0, // Fires between jobs (sequential worker)
]);
```

</TabItem>
<TabItem label="Go">

```go
worker := bunqueue.NewWorker("queue", processor, bunqueue.WorkerOptions{
    HeartbeatIntervalS: 5, // Heartbeats are disabled by default in Go
})
```

</TabItem>
<TabItem label="Rust">

```rust
let worker = Worker::new("queue", processor, WorkerOptions {
    heartbeat_interval: Some(Duration::from_secs(5)), // None disables
    ..Default::default()
});
```

</TabItem>
<TabItem label="Elixir">

```elixir
worker = Bunqueue.Worker.new("queue", handler, heartbeat_interval: 5_000)  # ms
```

</TabItem>
</Tabs>

Keep the heartbeat interval shorter than the queue's `stallInterval` to avoid false positives. See [Stall Detection](/guide/stall-detection/).

## Lock-based ownership

With `useLocks: true` (the default), each pulled job gets a lock, a temporary
claim that says "this worker owns this job". The lock is renewed by heartbeats
(`lockDuration` sets its TTL) and must be presented when completing or failing
the job. Delivery is exclusive while the lease is valid. After expiry, the
broker may redeliver while the stale handler is still running, but its old token
can no longer acknowledge the job. A redelivery gets a fresh token and a new
local processing generation even when the same Worker instance receives it.
Only that current generation is heartbeated and allowed to publish an automatic
outcome; completion or cleanup from the stale handler cannot remove the new
lease. This is at-least-once processing, so handlers must still be idempotent.

Locks matter most in **server mode** with multiple workers. In embedded mode with a single process you can trade the safety for a bit of throughput:

<Tabs syncKey="lang">
<TabItem label="Bun">

```typescript
const worker = new Worker('queue', processor, {
  embedded: true,
  useLocks: false, // Rely on stall detection only
});
```

</TabItem>
<TabItem label="Node.js / Deno">

```typescript
const worker = new Worker('queue', processor, {
  embedded: false,
  useLocks: false, // Rely on stall detection only
});
```

</TabItem>
<TabItem label="Python">

```python
def process(job):
    job.extend_lock(60_000)  # optional explicit lease extension
    return handle(job)

worker = Worker("queue", process, lock_ttl_ms=30_000)
```

</TabItem>
<TabItem label="PHP">

```php
$worker = new Worker('queue', function (Bunqueue\Job $job) {
    // PHP is sequential, so extend manually before work longer than the TTL.
    $job->extendLock(60000);
    return processJob($job);
}, ['lockTtlMs' => 30000]);
```

</TabItem>
<TabItem label="Go">

```go
worker := bunqueue.NewWorker("queue", func(job *bunqueue.Job) (any, error) {
    if err := job.ExtendLock(60_000); err != nil { return nil, err }
    return processor(job)
}, bunqueue.WorkerOptions{LockTtlMs: 30_000})
```

</TabItem>
<TabItem label="Rust">

```rust
let worker = Worker::new("queue", |job| {
    job.extend_lock(60_000)
        .map_err(|error| ProcessError::retryable(error.to_string()))?;
    processor(job)
}, WorkerOptions {
    lock_ttl_ms: 30_000,
    ..Default::default()
});
```

</TabItem>
<TabItem label="Elixir">

```elixir
# Elixir always uses lock ownership and renews it automatically.
worker = Bunqueue.Worker.new("queue", handler, lock_ttl: 30_000)
```

</TabItem>
</Tabs>

*The other language SDKs always use lock-based ownership: every pulled job carries a lock token whose TTL is set by `lockTtlMs` / `lock_ttl_ms` / `LockTtlMs` / `lock_ttl`, renewed by heartbeats. A single long-running handler can extend its own lease with `job.extendLock(ms)` (PHP), `job.extend_lock(ms)` (Python, Rust), or `job.ExtendLock(ms)` (Go). The canonical TypeScript Job uses `job.extendLock(token, duration)`.*

In both TypeScript packages, locks can also be extended explicitly: `worker.extendJobLocks(jobIds, tokens, duration)`.

## Where to go next

| Guide | What it covers |
|---|---|
| [Worker](/guide/worker/) | Create a worker and process your first job |
| [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches |
| [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do |
| [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest |
| [Worker Error Handling, Retries and Backoff](/guide/worker/errors/) | Retries, backoff, timeouts and giving up |
| [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work |
| [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers |
| [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults |