Workhorse
Executing work

Workers

Register handlers, size concurrency, tune the lease, and drain active work safely.

Jobs sit in PostgreSQL until a process runs them. That process is a Worker: a loop that claims ready jobs, runs your handlers, and records each result. This page covers everything between new Worker(queue) and a clean shutdown.

Register handlers and run

Worker.handle binds a job type to an async function. Registration chains, so one worker can serve many types. Each handler receives the payload and a HandlerContext, and returns a JSON result that becomes the job's durable outcome.

const worker = new Worker(queue, { queues: ["email", "billing"], concurrency: 5 });
worker.handle("email.send", async (payload: { to: string }, ctx) => {
  await mailer.send(payload.to, { signal: ctx.signal });
  return { deliveredTo: payload.to };
});
await worker.run();

run loops until you stop it; pass an AbortSignal as worker.run(signal) to tie its lifetime to your process. runOnce performs a single claim-and-run pass and returns whether it found work — useful in tests and scripts.

Python exposes this lifecycle through synchronous Worker and asynchronous AsyncWorker. Both rotate across configured queues, fill a bounded set of slots, and drain active handlers before run returns. AsyncWorker uses dedicated native Psycopg or asyncpg query and notification connections, and its handler context methods are awaitable. Both surfaces share the same claim, heartbeat, batch, settlement, telemetry, and drain core. Workers in all three languages also participate in the worker registry. Python's handle_batch follows the same grouping contract described in Batch handlers.

Waiting without constant polling

An idle worker listens for committed wake hints and receives only notifications for its configured queues. Promotion and recovery notify each affected queue separately, so activity on one queue does not wake workers assigned to another. Notifications wake dispatch without changing the cadence of maintenance or worker registration, while bounded polling still covers a lost listener or message. Workers briefly stagger notification-triggered claims. Workers without an active listener back off after consecutive empty checks and reset that delay when a claim succeeds.

The HandlerContext carries the claimed job, its AbortSignal, checkpoints, durable waits, and progress. Every context method writes under the current fence, so handler code cannot accidentally write as a stale owner after the job moves on.

The options that matter

const worker = new Worker(queue, {
  queues: ["email", "billing"], concurrency: 5, leaseMs: 30_000,
  workerId: "email-worker-1",
});
  • concurrency caps simultaneous handlers in this one Worker. Replicas multiply that capacity. When the fleet must share one budget, use concurrency policies; for a shared start rate, use rate limits.
  • leaseMs is how long a claim owns a job before the database may recover it. One background timer batches every active lease owned by the worker — every heartbeatMs, defaulting to a third of leaseMs. A shorter lease means faster recovery after a crash; a longer one tolerates worse network pauses.
  • workerId identifies the lease owner. Leave it generated unless a process manager guarantees uniqueness: two live workers sharing one identity collide over ownership. The default embeds host and pid, so fleet views stay readable.
  • retryDelayMs overrides the persisted retry policy — a number, or (attempt, job) => number | undefined, where undefined defers to the database.
  • scheduleNamespaces and scheduleCatchupLimit opt this worker into offering recurring schedules.

Python uses schedule_namespaces and schedule_catchup_limit for the same boundary. Go uses WorkerOptions.ScheduleNamespaces and WorkerOptions.ScheduleCatchupLimit.

  • queues lets one worker rotate across several queues under the same identity and concurrency budget. Use queue for one queue; omit both options to use the client default.

Group queues that share handlers and operational policy into one multi-slot worker. Add a second worker when work needs a different lease, retry override, schedule namespace, or handler registry.

Process jobs in batches

Worker.handleBatch groups claimed jobs of one type when one provider call can process several payloads efficiently. Every member still occupies one concurrency slot and keeps its own lease, fence, cancellation signal, progress, retry budget, and final result.

The TypeScript handleBatch, Python handle_batch, and Go HandleBatch forms are shown together in Batch handlers, including their positional success and failure results.

The callback must return one ordered outcome per member. Batch contexts omit durable waits, signals, human decisions, and child joins because one member cannot suspend independently inside a shared invocation. See Batch handlers for grouping and failure rules.

Run workers in a dedicated process

Queue depth and HTTP traffic rarely scale together, so production deployments put workers in their own process. defineWorkerProcess describes the adapter and workers; the workhorse worker CLI loads the compiled definition and adds signal handling and supervision.

export default defineWorkerProcess({
  adapter: () => createWorkhorseAdapter(adapterOptions),
  workers: [{
    options: { queues: ["email", "billing"], concurrency: 5 },
    configure(worker) { worker.handle("email.send", sendEmail); },
  }],
});

The process owns its database resources, worker loops, optional probes, and shutdown. Compile the definition before the CLI imports it — the CLI does not install a TypeScript loader.

The first termination signal stops claims and drains active jobs. A second signal or an expired deadline exits immediately, leaving PostgreSQL to recover any active leases.

Go applications create the same boundary with signal.NotifyContext, then pass that context to Worker.Run. A handler panic becomes a recorded attempt failure, so it does not terminate the worker process or prevent later jobs from running.

Pause, stop, and drain

Three verbs cover controlled slowdown, and each answers a different situation:

  • worker.pause() stops this worker from claiming while active handlers finish. worker.resume() lets it claim again; worker.isPaused() reads the effective state.
  • worker.stop() drains: no new claims, active handlers run to completion, then run resolves. On SIGTERM, the worker process does this for you.
  • Admin.setWorkerPaused stores an operator pause in worker_registry. TypeScript, Python, and Go workers pick it up on their next registration refresh, so a dashboard can pause a process it does not host. A local resume cannot clear an operator pause, and an operator pause dies with the process incarnation it named.

If work must stop durably — surviving worker restarts — pause the queue with Admin.pauseQueue, not the worker.

Read fleet state carefully

Worker.runtimeState() reads a TypeScript worker's local process state. The dashboard and Admin.listWorkers read the durable registry that every runtime refreshes on an interval. Registry slot counts are recent observations, not synchronous views of another event loop — treat them as a moment-ago snapshot.

TypeScript configures the cadence with registryIntervalMs, Python uses registry_interval_ms, and Go uses RegistryInterval. A registration error does not stop dispatch, and the worker keeps its last remote-pause decision until a later refresh succeeds.

A worker that dies stops refreshing and goes stale in the registry. Automatic maintenance makes that row eligible for removal after one minute. Its jobs are not lost: their leases expire and recovery makes them claimable again.

The protocol names make this lifecycle greppable. claim_many_v1 applies claim_v1 to each selected job and writes its job_runtime lease. heartbeat_many_v1 submits every active lease and extends the accepted expires_at values. recover_expired_v1 makes abandoned work ready. Workers claim from workhorse_jobs, TypeScript configures fallback polling with pollMs, and processes commonly begin graceful drain after SIGINT.

Next


Exact worker options, registry fields, heartbeat behavior, and process lifecycle: architecture reference.