---
title: 'Process Managers'
source: 'https://academia.sh/en/courses/backend-production/process-managers'
course: 'Server Security and Going to Production'
language: en
updated: '2026-08-23T07:00:26+00:00'
license: 'CC BY-SA 4.0'
---

# Process Managers

Two settings in the layer beneath the application: how immediate, backoff, and limited-attempt restart policies for a crashing process affect the dropped-request count; how many times the start loop turns under each policy when a process never starts; the difference between killing a process and letting it discard records and stay up when a memory limit is crossed; and how the wait time granted after a stop signal affects the number of drained requests.

The previous lesson configured the layer in front of the application, and every one of its
eight runs assumed one thing: the process was up, started by hand, stopped by hand when the job
finished, and never crashed in between.

In production, another component starts the process, restarts it when it crashes, decides how
much memory it may use, and warns it before stopping it: the process manager. The proxy's
settings changed what a request carried; this layer's settings change whether it gets answered
at all.

## Two Settings, One Worker

The process manager has two settings to measure. The first is the restart policy: how long to
wait before restarting an exited process, and after how many attempts to give up. The second is
the resource limit: how much memory a process may hold, and what happens when it is crossed. A
third setting stands apart from both, just as silent: the wait time granted after a stop signal.

- **SD1.** The worker allocates 2 MB per open loan record and reports its running total to the
  manager; the limit is enforced through that report. In a real deployment the value is read
  from the operating system.
- **SD2.** A request's work takes a fixed duration. In the memory measurement, responses are
  staggered 100 milliseconds apart by acceptance order, so both the instant the limit is crossed
  and how many requests are in flight then are determined.
- **SD3.** This lesson runs a single worker process; copy count is the next lesson's subject.

The worker is the running form of the loan application, and takes four behaviors from a mode
string.

```js
// topology/worker.mjs — loan worker process. A mode string sets its behavior:
//   count=<n>  exits after answering n requests   |  start-fail=1  exits before it starts listening
//   work=<ms>  one request's duration   step=<ms>  work grows by acceptance order (responses stagger)
//   memory=soft  at the limit, discards the oldest half of records and stays up
//   shutdown=graceful|hard  on the stop signal, drains pending requests or exits immediately
import { createServer } from "node:http";

const [port, modeText] = [Number(process.argv[2]), process.argv[3] ?? ""];
const MODE = Object.fromEntries(modeText.split(",").filter(Boolean).map((p) => p.split("=")));
const RECORD_MB = 2;                      // memory held per open loan record
const LIMIT_MB = 40;                      // soft-limit threshold
const held = [];
let responded = 0, discarded = 0, accepted = 0, closing = false;

if (Number.isInteger(port) === false) { console.log("usage: node topology/worker.mjs <port> <mode>"); process.exit(0); }
if (MODE["start-fail"] === "1") process.exit(9);

const server = createServer((req, res) => {
  res.sendDate = false;
  if (closing) { res.writeHead(503).end("closing"); return; }
  const order = accepted++;
  held.push(new Array((RECORD_MB * 1024 * 1024) / 8).fill(1));    // an open loan record sits in memory
  if (MODE.memory === "soft" && held.length * RECORD_MB > LIMIT_MB) {
    discarded += held.splice(0, held.length >> 1).length;         // oldest half discarded, process lives
  }
  setTimeout(() => {
    responded += 1;
    res.writeHead(200, { "x-held-mb": String(held.length * RECORD_MB) }).end("loan");
    if (process.send !== undefined) process.send({ heldMB: held.length * RECORD_MB, responded, discarded });
    if (MODE.count !== undefined && responded >= Number(MODE.count)) process.exit(9);
  }, Number(MODE.work ?? 40) + order * Number(MODE.step ?? 0));
});

process.on("SIGTERM", () => {
  if (MODE.shutdown === "graceful") { closing = true; server.close(() => process.exit(0)); }
  else process.exit(0);                    // pending requests drop without ever seeing a response
});
server.listen(port, "127.0.0.1");
```

The manager starts the worker, listens for its reports, catches its exit, and applies policy.

```js
// topology/manager.mjs — process manager. Setting string:
//   policy=immediate|backoff|limited:<n>   memory=none|hard:<mb>
//   duration=<ms>  0 waits for the stop signal and prints a summary; >0 starts a graceful stop then
//   wait=<ms>  time granted after the stop signal before a hard kill
import { fork } from "node:child_process";

const [port, configText, workerMode, label] = [process.argv[2], process.argv[3] ?? "", process.argv[4] ?? "", process.argv[5] ?? "-"];
const OPTS = Object.fromEntries(configText.split(",").filter(Boolean).map((p) => p.split("=")));
const BACKOFF = [100, 200, 400, 800, 1600];       // backoff ladder (ms)
let attempts = 0, delay = 0, kills = 0, peak = 0, discarded = 0, gaveUp = false, stopping = false, child = null;

function start() {
  let killed = false;
  child = fork("topology/worker.mjs", [port, workerMode], { stdio: ["ignore", "ignore", "ignore", "ipc"] });
  child.on("message", (m) => {
    peak = Math.max(peak, m.heldMB);
    discarded = m.discarded;
    const hard = (OPTS.memory ?? "none").startsWith("hard:") ? Number(OPTS.memory.split(":")[1]) : Infinity;
    if (m.heldMB > hard && killed === false) {                        // limit crossed: process is killed
      killed = true;
      kills += 1;
      child.kill("SIGKILL");
    }
  });
  child.on("exit", () => { if (stopping === false) retry(); });
}

function retry() {
  const max = (OPTS.policy ?? "").startsWith("limited:") ? Number(OPTS.policy.split(":")[1]) : Infinity;
  if (attempts >= max) { gaveUp = true; return; }
  const g = OPTS.policy === "backoff" ? BACKOFF[Math.min(attempts, BACKOFF.length - 1)] : 0;
  attempts += 1;
  delay += g;
  setTimeout(start, g);
}

if (port === undefined) console.log("usage: node topology/manager.mjs <port> <config> <worker-mode> <label>");
else {
  setInterval(() => {}, 60000);          // stays up for the summary even after giving up
  start();
  process.on("SIGTERM", () => {
    stopping = true;
    child?.kill("SIGKILL");
    console.log(`${label} manager: ${attempts} restarts, ${delay} ms delay, ${kills} kills, ` +
      `peak ${peak} MB, ${discarded} records discarded${gaveUp ? ", gave up" : ""}`);
    process.exit(0);
  });
  if (Number(OPTS.duration ?? 0) > 0) setTimeout(() => {
    stopping = true;
    child.kill("SIGTERM");                                            // graceful stop request first
    const hard = setTimeout(() => child.kill("SIGKILL"), Number(OPTS.wait ?? 500));
    child.on("exit", () => { clearTimeout(hard); process.exit(0); }); // clean if it exits before the wait ends
  }, Number(OPTS.duration));
}
```

The load generator sends requests at a fixed interval or all at once, and separates three
outcomes: answered, cleanly rejected, dropped.

```js
// topology/load.mjs — load generator. Mode "spaced": count requests, interval ms apart.
// Mode "concurrent": count requests at once. A request that fails to connect or closes without a
// response counts as "dropped".
import { request } from "node:http";

const [port, mode, count, interval, label] = [Number(process.argv[2]), process.argv[3], Number(process.argv[4]), Number(process.argv[5]), process.argv[6] ?? "-"];
const wait = (ms) => new Promise((c) => setTimeout(c, ms));
let answered = 0, rejected = 0, dropped = 0;

const one = () => new Promise((resolve) => {
  const r = request({ port, path: "/loan", agent: false }, (y) => {
    y.resume();
    y.on("end", () => { if (y.statusCode === 200) answered += 1; else rejected += 1; resolve(); });
    y.on("aborted", () => { dropped += 1; resolve(); });
  });
  r.on("error", () => { dropped += 1; resolve(); });
  r.end();
});

if (Number.isInteger(port) === false) console.log("usage: node topology/load.mjs <port> <mode> <count> <interval> <label>");
else {
  if (mode === "concurrent") await Promise.all(Array.from({ length: count }, one));
  else {
    const open = [];
    for (let i = 0; i < count; i += 1) { open.push(one()); await wait(interval); }
    await Promise.all(open);
  }
  console.log(`${label} load: ${count} requests, ${answered} answered, ${rejected} rejected, ${dropped} dropped`);
}
```

## A Crashing Process and One That Never Starts

The first setup meets the same crash with three policies. The worker exits after every fourth
response, and the load sends 24 requests 100 milliseconds apart. The same three policies then
run against a worker that never starts, exiting before it begins listening.

```bash
# measure1.sh — same crash, three restart policies. Worker exits after every 4th response.
run() {                                   # run <policy> <worker-mode> <count> <label>
  node topology/manager.mjs 8941 "policy=$1,duration=0" "$2" "$4" & M=$!
  sleep 0.5
  node topology/load.mjs 8941 spaced "$3" 100 "$4"
  kill -TERM $M; wait $M; sleep 0.2
}

run immediate       "count=4,work=40" 24 "crash / immediate      "
run backoff         "count=4,work=40" 24 "crash / backoff        "
run limited:2       "count=4,work=40" 24 "crash / limited (2)    "
run immediate       "start-fail=1" 8 "start-fail / immediate "
run backoff         "start-fail=1" 8 "start-fail / backoff   "
run limited:2       "start-fail=1" 8 "start-fail / limited 2 "
```

```
crash / immediate       load: 24 requests, 20 answered, 0 rejected, 4 dropped
crash / immediate       manager: 5 restarts, 0 ms delay, 0 kills, peak 8 MB, 0 records discarded
crash / backoff         load: 24 requests, 14 answered, 0 rejected, 10 dropped
crash / backoff         manager: 3 restarts, 700 ms delay, 0 kills, peak 8 MB, 0 records discarded
crash / limited (2)     load: 24 requests, 12 answered, 0 rejected, 12 dropped
crash / limited (2)     manager: 2 restarts, 0 ms delay, 0 kills, peak 8 MB, 0 records discarded, gave up
start-fail / immediate  load: 8 requests, 0 answered, 0 rejected, 8 dropped
start-fail / immediate  manager: 43 restarts, 0 ms delay, 0 kills, peak 0 MB, 0 records discarded
start-fail / backoff    load: 8 requests, 0 answered, 0 rejected, 8 dropped
start-fail / backoff    manager: 4 restarts, 1500 ms delay, 0 kills, peak 0 MB, 0 records discarded
start-fail / limited 2  load: 8 requests, 0 answered, 0 rejected, 8 dropped
start-fail / limited 2  manager: 2 restarts, 0 ms delay, 0 kills, peak 0 MB, 0 records discarded, gave up
```

The first three pairs meet the same crash, and the ranking flips from what one might expect.
Immediate answered 20 of 24 requests, four dropped (this run). Backoff answered 14, ten dropped
— its ladder added 100, 200, and 400 milliseconds of waiting, 700 total, and every request
arriving during that wait was dropped. The limited policy gave up after two attempts, and all
twelve remaining requests dropped; that number comes from the policy's definition, not the run.

The answered and dropped counts come from a fixed-interval load and depend on the machine's load
at that moment; what stays run-independent is the wait time and attempt count each policy adds.

When the crash is transient, the ranking is clear: backoff is pure loss if the next attempt
would have succeeded anyway. The ladder's reason for existing shows up in the last three rows.

When the worker never starts, all three policies give the same result — eight of eight requests
drop. The difference is not on the request side but in the work the machine burns: immediate
started process after process in the same 0.8-second window — dozens, 43 in this run — backoff
started four, limited two. Two of these numbers are run-independent: the ladder's 100, 200, 400,
and 800 milliseconds fit four attempts into that window, and limited stops at two by definition.
Immediate's count is the ratio of window length to process-start cost, with no upper bound: it
grows with the window and with a faster machine.

One more distinction is the quietest of all. The limited policy prints that it gave up; the
other two keep trying forever. A system that is not up looks like it is "starting" for as long
as something keeps trying to start it.

## Crossing the Limit: Killing and Slowing

The second setup applies the memory limit in two places. At the hard limit, the manager reads
the report and kills the process that crosses the threshold. At the soft limit, the worker sees
its own threshold, discards the oldest half of its records, and stays up. Twenty-four requests
are sent at once, so all are in flight when the limit is crossed. A third setup sends the stop
signal while six requests are in flight.

```bash
# measure2.sh — memory limit two ways, then requests in flight when the stop signal lands.
memory() {                                # memory <manager-config> <worker-mode> <label>
  node topology/manager.mjs 8942 "$1" "$2" "$3" & M=$!
  sleep 0.5
  node topology/load.mjs 8942 concurrent 24 0 "$3"
  kill -TERM $M; wait $M; sleep 0.2
}
memory "policy=immediate,memory=hard:40,duration=0" "work=200,step=100"             "hard limit 40 MB"
memory "policy=immediate,memory=none,duration=0"    "work=200,step=100,memory=soft" "soft limit 40 MB"

shutdown() {                              # shutdown <manager-config> <worker-mode> <label>
  node topology/manager.mjs 8943 "$1" "$2" "$3" & M=$!
  sleep 0.5
  node topology/load.mjs 8943 concurrent 6 0 "$3"
  wait $M; sleep 0.2
}
shutdown "policy=immediate,duration=700,wait=500" "work=300,shutdown=graceful" "graceful, wait 500 ms"
shutdown "policy=immediate,duration=700,wait=50"  "work=300,shutdown=graceful" "graceful, wait  50 ms"
shutdown "policy=immediate,duration=700,wait=500" "work=300,shutdown=hard"     "hard shutdown        "
```

```
hard limit 40 MB load: 24 requests, 1 answered, 0 rejected, 23 dropped
hard limit 40 MB manager: 1 restarts, 0 ms delay, 1 kills, peak 48 MB, 0 records discarded
soft limit 40 MB load: 24 requests, 24 answered, 0 rejected, 0 dropped
soft limit 40 MB manager: 0 restarts, 0 ms delay, 0 kills, peak 28 MB, 10 records discarded
graceful, wait 500 ms load: 6 requests, 6 answered, 0 rejected, 0 dropped
graceful, wait  50 ms load: 6 requests, 0 answered, 0 rejected, 6 dropped
hard shutdown         load: 6 requests, 0 answered, 0 rejected, 6 dropped
```

The hard limit is crossed at 48 MB and the process is killed: one of twenty-four requests is
answered, twenty-three drop, one restart happens. At the soft limit, all twenty-four are
answered, none drop, no restart happens; the cost is ten discarded records and a peak of 28 MB
instead of 48.

Both rows meet the same threshold under the same load, and what gets lost changes. Under
killing, the requests in flight are lost: twenty-three loan operations are cut off mid-way with
no response. Under discarding, memory history is lost: the process lives, the request is
answered, and a discarded record has to be read again if needed. The first is a visible
failure, the second a slowdown.

Twenty-three is the number of requests in flight at the instant the limit was crossed: killing's
bill is measured by the work being carried at that instant.

The soft limit's silent side is here too: no counter ever turns red. In a setup that does not
report a discarded-record count, this row looks flawless, and the fact that the memory threshold
is being crossed constantly is written down nowhere.

## Wait Time

The last three rows send the stop signal while six requests are in flight. Each request's work
takes 300 milliseconds.

Under graceful shutdown, the worker stops accepting new requests and drains the ones waiting; at
a 500-millisecond wait, all six were drained. The same code, wait cut to 50 milliseconds, lost
all six: the worker began draining, the manager did not wait for it to finish, and killed the
process. Under hard shutdown, the worker never drained at all, and the result was the same.

Two of the three rows give the same result for different reasons: one has no draining, the
other has draining but not enough time granted. On the code side the second is written
correctly; on the setting side both are equally a loss. Graceful-shutdown code does not run to
completion unless the wait time exceeds the application's longest request — and nowhere is it
recorded that the code never ran.

## Where the Setting Lives

| Setting | Location | How many places | Silent result of a wrong value |
|---|---|---|---|
| Restart policy | process manager | 1 (each node) | extra dropped requests on a transient crash, an endless start loop on a permanent one |
| Attempt limit | process manager | 1 | without a limit, the failure looks like "starting" |
| Memory limit and form | manager (hard) or application code (soft) | 2 | every request in flight lost under killing, a silent slowdown under discarding |
| Wait time | process manager | 1 | graceful-shutdown code is written and never runs |

Three of the four rows sit in a single place and are a single number. Their effects show up only
during a crash, or during a release.

## Summary

- Under the same transient crash, immediate answered 20 of 24 requests, backoff 14, and the
  policy limited to two attempts 12 (the first two figures are from this run); the 700
  milliseconds backoff added turned directly into dropped requests.
- When the process never started, all three policies lost eight of eight requests; the
  difference was in work spent — immediate started dozens of processes in the same window (43
  in this run), backoff four, limited two, and only the last one reported it gave up.
- The numbers from the backoff ladder and the attempt limit are run-independent; immediate's
  start count has no upper bound.
- Enforced as a hard limit, twenty-three of twenty-four requests dropped and one restart
  happened; enforced as a soft limit, none dropped and the cost was ten discarded records. The
  dropped-request count equals the work in flight at the instant the limit was crossed.
- Under graceful shutdown, a 500-millisecond wait drained all six requests in flight; the same
  code at 50 milliseconds lost all six, matching hard shutdown.

## Next Step

These two lessons worked with a single process: the proxy forwarded to a single target, the
manager started a single worker, and the memory limit was that one process's limit. There are
two ways to meet more load, and both touch this number: give the process more resources, or
open more processes. Both meet the same request and return the same response, and produce very
different bills behind the scenes. The next lesson meets the same load with one large process
and with four small ones; it measures total memory, startup time, and the multiplier from each
process opening its own connection pool — a setting correct for one process sends the store four
times the connections once four processes share it.
