Workhorse
Operating

Worker processes

Run workers as dedicated processes with one configuration file, graceful drain on SIGTERM, and probe endpoints for your orchestrator.

Your web tier and your job workers have different failure modes, different scaling curves, and different memory budgets. A dedicated worker process keeps them apart: web replicas serve traffic, worker replicas process jobs, and the two meet only in PostgreSQL. Workhorse packages the whole process lifecycle — startup, signals, drain, probes — so a worker deployment is one configuration file and one command.

If the web tier runs in a function or edge isolate, use the serverless and edge matrix to check its enqueue path. The worker still runs here, in continuous compute with its own process lifecycle.

Define the process

defineWorkerProcess type-checks a configuration and returns it unchanged. The definition owns its adapter — and through it, its database pool — plus one entry per worker.

src/workhorse.worker.ts
import { createWorkhorseAdapter, defineWorkerProcess, Pool } from "@stablemates/workhorse";
import { generateReport, sendEmail } from "./jobs.js";

export default defineWorkerProcess({
  adapter() {
    const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
    return createWorkhorseAdapter({
      database: pool,
      adaptTransaction: (transaction: typeof pool) => transaction,
      close: () => pool.end(),
    });
  },
  workers: [
    {
      options: { concurrency: 8 },
      configure(worker) {
        worker.handle("email.send", sendEmail);
        worker.handle("report.generate", generateReport);
      },
    },
  ],
  shutdownTimeoutMs: 25_000,
  probes: { hostname: "0.0.0.0", port: 9090 },
});

Compile it, then run the default export with the packaged CLI:

workhorse worker --config ./dist/workhorse.worker.js

The CLI imports JavaScript the current Node.js process understands — it bundles no TypeScript loader, so production runs compiled output. --shutdown-timeout-ms overrides the configured drain deadline per deployment.

If you already own process supervision, skip the CLI: startWorkerProcess(definition) starts workers without installing global signal handlers, and runWorkerProcess(definition, options) adds the standalone Node.js lifecycle — signal handling, exit codes, forced exit.

Drain, don't drop

On the first SIGTERM or SIGINT, the process marks readiness false and calls Worker.stop on every worker. Workers stop asking PostgreSQL for jobs; active handlers keep their leases and finish. If a claim was already in flight when the signal arrived, it can still commit — the process now owns that lease, so it drains that job too.

The drain is bounded by shutdownTimeoutMs (25 seconds by default). If the deadline expires, the process exits and leaves the remaining leases in PostgreSQL, where ordinary fenced recovery hands them to another worker. Nothing is lost either way — a drained job finished here, an abandoned one finishes elsewhere.

Two boundaries worth knowing:

  • Process shutdown does not abort HandlerContext.signal and creates no cancellation evidence. When the job itself must stop, call queue.cancel.
  • Keep the drain deadline shorter than your platform's termination window. A second signal requests immediate exit, so handlers must stay safe for recovery after a forced stop — which checkpointed, idempotent handlers already are.

Probes for your orchestrator

The optional probe listener serves exactly two endpoints: liveness (/livez) while the process exists, readiness (/readyz) while workers can still ask for jobs. Readiness drops the moment draining begins, so a rolling deployment routes nothing to a process on its way out.

The probe server is not application ingress. It exposes no job data, no queue data, no metrics, and no mutations — safe to bind wherever your orchestrator needs it.

If startup fails partway, the process closes every resource it already created. If any worker loop exits unexpectedly, the process stops its siblings and exits, so your supervisor restarts the whole unit rather than running it half-alive.

One process, one pool

Give each process its own pool and let the adapter's close shut it down after the last worker drains. Size total capacity across replicas: heartbeats, handler queries, and maintenance share those connections. Each worker also registers itself in PostgreSQL on its registryIntervalMs cadence, which is what lets a dashboard on another machine see the fleet — see Operations.

Next


Exact signal handling, shutdown deadlines, probes, and recovery behavior: architecture reference.