[]previous and next page

Using every core

One Node process runs your JavaScript on one core.

For agentsUpdated

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.

serve.tsts
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`);
sh
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 --workers when a page of the build sets revalidate, and serve({ workers }) for each in-memory store that announces itself with markProcessLocalStore(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=rr in the environment to get round robin there too. Windows has no SIGUSR2, and a stop request from the system (taskkill) ends the process without a signal: stop the pool with Ctrl+C (SIGINT) or server.close(), and restart it with server.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 how node:cluster works.
  • SO_REUSEPORT (reusePort in 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.