Using every core
One Node process runs your JavaScript on one core.
One Node process runs your JavaScript on one core. To use a machine with several, run several processes that share the
port. serve({ workers }) does that with node:cluster; dusk start --workers and the generated server.mjs
(DUSK_WORKERS) use it too.
import { fileURLToPath } from "node:url";
import { serve, workerInfo } from "@shadow-js/dusk/node";
import type { Handler } from "@shadow-js/dusk/server";
const entry = (await import(new URL("./dist/server/entry.js", import.meta.url).href)) as {
handler: Handler;
};
const server = await serve(entry.handler, {
port: Number(process.env["PORT"] ?? 3000),
host: process.env["HOST"] ?? "0.0.0.0",
static: { dir: fileURLToPath(new URL("./dist/client/", import.meta.url)) },
workers: "auto",
});
if (server.role === "primary") console.log(`${server.pool?.workers} workers on ${server.url}`);
else console.log(`worker ${workerInfo().id} listening`);
dusk start --workers auto # one process per core, at most 16
dusk start --workers 4
DUSK_WORKERS=4 node dist/node/server.mjs
workers is a whole number from 1 to 256, or "auto": os.availableParallelism() (which honors CPU affinity; inside
a container check what it returns, node -p "os.availableParallelism()", and pass a number if it reports the host's cores),
at most 16. Absent, or 1, is one process: nothing described here is loaded or changed.
With more than one, the process that starts the server becomes the primary. It serves no request. It forks the
workers, and each worker is a copy of the same program (the same server.mjs, or the same dusk start command), so the
program loads your app once per worker. serve resolves in the primary once every worker is listening, with a handle
whose role is "primary" and whose pool has workers, pids(), restart() and failed. The primary's server
never listens. In a worker role is "worker". dusk start --workers does not load the app in the primary at all.
| What happens | What the primary does |
|---|---|
| The port is taken | serve rejects with EADDRINUSE before any worker starts; dusk start exits 1 with DUSK_PORT_IN_USE. |
| A worker dies before the pool was ready | The start fails at once and the others are stopped: a config error would only repeat. |
| A worker crashes later | It is replaced after 500 ms, 1 s, 2 s, 4 s and so on, at most 30 s (supervise.restartBackoffMs, maxRestartBackoffMs). A worker that stayed up for 30 s starts the delay over. |
| Crashes in a loop | 5 crashes within 30 s (supervise.maxCrashes, crashWindowMs) stop the restarts: the primary drains the workers that are left, prints crash loop: 5 workers crashed within 30 s ... and exits 1 (dusk start: a failure with DUSK_FAILED). |
| A worker never listens | It is killed after supervise.readyTimeoutMs (30 s) and counts as a crash. |
SIGUSR2 |
A rolling restart: a new worker starts, and once it is listening the old one drains, one pair at a time. The pool never has fewer workers than asked for, and a new worker that fails to start stops the restart and leaves the old ones serving. On Windows, which has no SIGUSR2, call server.pool.restart(). |
SIGTERM, SIGINT |
Every worker stops accepting, finishes the requests in flight for up to gracefulShutdownMs, and exits; then the primary exits 0. A worker still running after gracefulShutdownMs plus 5 s is killed. |
A worker is ready only when it is listening, and the primary counts it then. A worker that is replaced during a rolling restart first
closes its listener (the primary sends it no new connection), keeps answering on the connections it has with Connection: close
so that a client moves to the next worker without a reset or a 503, and after at most a second closes what is idle. Requests in
flight run to completion within gracefulShutdownMs. To roll out a new build without a gap, replace the files on disk and
send SIGUSR2 to the primary: every new worker is a new process, so it loads the new files.
Outside production, every response carries x-dusk-worker: <id>, the id of the worker that answered, so you can see the pool
at work (curl -i). The id is the worker's number (1, 2, ...), and a replacement gets a new one. In production
(NODE_ENV=production, and always under dusk start) the header is not sent. In code, workerInfo() from
@shadow-js/dusk/node returns { clustered, id, count, pid }.
What is per worker#
Every worker has its own memory. Anything that lives in memory is therefore one copy per worker:
- The page cache (ISR) and the data cache over the in-memory store: a page revalidated or a tag invalidated in one worker
is not seen by the others, and each worker renders the page itself once. The primary prints one warning at start:
dusk start --workerswhen a page of the build setsrevalidate, andserve({ workers })for each in-memory store that announces itself withmarkProcessLocalStore(label)from@shadow-js/dusk/node. Give the caches a shared store (a Redis-like service or a shared file store): see Shared storage. - Sessions and rate limits kept in memory: a client counts against the worker its connection reached. Use a shared store for both.
- WebSocket and server-sent event connections: a message published in one worker reaches only the clients connected to that worker. Fan out through a shared service (a message bus) if the clients must see each other.
- Module state of your own (a counter, a
Map).
Platforms and balance#
Node hands each new connection to one worker, so the balance is per connection, not per request. A browser or proxy that reuses a few keep-alive connections keeps using the same workers: put a proxy in front that opens many connections to the app, or accept an uneven spread.
- Linux and macOS: the primary accepts the connections and gives them to the workers in turn (round robin). That is Node's default there, and it balances well.
- Windows: Node's default is to let the operating system hand connections to the workers, which on Windows is often
uneven (one worker gets most of them). Set
NODE_CLUSTER_SCHED_POLICY=rrin the environment to get round robin there too. Windows has noSIGUSR2, and a stop request from the system (taskkill) ends the process without a signal: stop the pool with Ctrl+C (SIGINT) orserver.close(), and restart it withserver.pool.restart(). - A worker killed with
SIGKILL(the out-of-memory killer) can take with it a connection that the primary had just handed to it; that client sees a reset and retries. This is hownode:clusterworks. SO_REUSEPORT(reusePortin Node 22.12 and later, Linux) is not needed: the cluster module shares the listening socket itself.
Containers and orchestrators#
In Docker, Kubernetes, Nomad or any orchestrator, prefer one process per container and let the orchestrator scale: set the container's CPU limit to one core, run as many replicas as you need, and let the platform's health checks, rolling updates and autoscaling do what the pool does here. A pool inside a container hides workers from the orchestrator (a crashed worker is restarted by the primary, so the container never looks unhealthy), splits the container's memory limit, and doubles the layers of supervision. Workers make sense when:
- you run on a virtual machine or a bare-metal server, under systemd or a process manager, with no orchestrator;
- you have one container with several cores and no way to run replicas;
- you want to use the cores of a machine for a single deploy with a rolling restart and no load balancer in front.
If you do use a pool in a container, pass the number of workers that matches the container's CPU limit (auto counts the cores the
process may use, which is not always the limit), run the primary as the container's main process (the exec form of CMD, so the signals reach it),
and set the stop timeout above gracefulShutdownMs.