Emmanuel Akinfulubi

Production all 2 written

TypeScript with no build step, and the configuration, logging and shutdown habits that separate a script from a service.

47 TypeScript in Node, without a build step

TypeScript is JavaScript with type annotations: you write down what kind of value each variable and parameter holds, and a checker tells you when the code disagrees. Most large Node projects use it. Node can now run a .ts file directly, with nothing installed, by deleting the types and running the JavaScript that is left. That is convenient, and it has one consequence you must understand before relying on it: Node never checks the types.

A .ts file, run directly

node greet.ts

ran at build time

greet.ts

interface User {
  name: string;
  age: number;
}

function greet(user: User): string {
  return `Hello, ${user.name}. Next year you will be ${user.age + 1}.`;
}

const ada: User = { name: "Ada", age: 36 };
console.log(greet(ada));

// Data from outside -- a form, a request body -- with a promise about its shape.
const fromForm = JSON.parse('{ "name": "Alan", "age": "41" }') as User;
console.log(greet(fromForm));

console.log("TypeScript support in this node:", process.features.typescript);

check("the annotated code ran", greet(ada), "Hello, Ada. Next year you will be 37.");
check("and nothing checked that age was really a number", greet(fromForm).endsWith("be 411."), true);

What node printed, when the build ran it

node greet.ts

Hello, Ada. Next year you will be 37.
Hello, Alan. Next year you will be 411.
TypeScript support in this node: strip
ok   the annotated code ran
ok   and nothing checked that age was really a number

The command is just node greet.ts. The interface, the annotations and the as User were removed, and the JavaScript underneath ran. The second greeting shows what that means. as User is a promise from the programmer, not a check: the form sent the age as the string "41", and "41" + 1 is "411" (lesson 7). Node did not notice, because it does not look at types at all — and the TypeScript checker would not have noticed either, because JSON.parse returns any, which may be asserted to be anything. Types describe the data you trust. Data from outside still has to be checked at run time, as lesson 37's checkTodo did.

What stripping does

module.stripTypeScriptTypes

ran at build time

example.mjs

import { stripTypeScriptTypes } from "node:module";

const source = `function total(items: Item[], tax: number = 0.2): number {
  return items.reduce((sum, item) => sum + item.price, 0) * (1 + tax);
}`;

const stripped = stripTypeScriptTypes(source);
console.log(source);
console.log("--- becomes ---");
console.log(stripped.replaceAll(" ", "·"));

check("every line keeps its length, so positions do not move",
      stripped.split("\n").map((line) => line.length), source.split("\n").map((line) => line.length));

What node printed, when the build ran it

node example.mjs

function total(items: Item[], tax: number = 0.2): number {
  return items.reduce((sum, item) => sum + item.price, 0) * (1 + tax);
}
--- becomes ---
function·total(items········,·tax·········=·0.2)·········{
··return·items.reduce((sum,·item)·=>·sum·+·item.price,·0)·*·(1·+·tax);
}
ok   every line keeps its length, so positions do not move
(node:<pid>) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)

Node replaces each piece of type syntax with the same number of spaces (shown here as dots) and runs the result. Nothing moves, so there is no need for source maps: when a .ts file throws, the line and column in the stack trace are the line and column in your file. (The warning is about the stripTypeScriptTypes function, which is newer; running a .ts file, as the first panel did, prints none.)

An error in a .ts file, at the right place

dies with TypeError

distance.ts

type Point = {
  x: number;
  y: number;
};

function distance(a: Point, b: Point): number {
  return Math.hypot(a.x - b.x, a.y - b.y);
}

const points: Point[] = JSON.parse('[{ "x": 0, "y": 0 }]');
console.log(distance(points[0], points[1]));

What node printed as it died, when the build ran it

node distance.ts

file:///app/distance.ts:7
  return Math.hypot(a.x - b.x, a.y - b.y);
                            ^

TypeError: Cannot read properties of undefined (reading 'x')
    at distance (file:///app/distance.ts:7:29)
    at Object.<anonymous> (file:///app/distance.ts:11:13)
    at Module._compile (node:internal/modules/cjs/loader:1934:14)
    at Object..js (node:internal/modules/cjs/loader:2074:10)
    at Module.load (node:internal/modules/cjs/loader:1656:32)
    at Module._load (node:internal/modules/cjs/loader:1448:12)
    at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
    at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
    at node:internal/main/run_main_module:33:47

Node.js v26.7.0

Line 7, column 29 is exactly where b.x is in the file as written. (The bug is the same as the last one: the types said there were two points, and the data had one.)

Imports: the extension, and import type

Three ways to import from types.ts

ran at build time

types.ts

export interface Order {
  id: number;
  total: number;
}

export const currency = "GBP";

no-extension.ts

import type { Order } from "./types";
import { currency } from "./types";

const order: Order = { id: 1, total: 1250 };
console.log(order, currency);

type-as-value.ts

import { Order, currency } from "./types.ts";

const order: Order = { id: 1, total: 1250 };
console.log(order, currency);

right.ts

import type { Order } from "./types.ts";
import { currency } from "./types.ts";

const order: Order = { id: 1, total: 1250 };
console.log(order, currency);

The terminal, as the build ran it

node no-extension.ts   # exits 1
node:internal/modules/esm/resolve:272
    throw new ERR_MODULE_NOT_FOUND(
          ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/types' imported from /app/no-extension.ts
    at finalizeResolution (node:internal/modules/esm/resolve:272:11)
    at moduleResolve (node:internal/modules/esm/resolve:879:10)
    at defaultResolve (node:internal/modules/esm/resolve:1006:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20)
    at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38)
    at ModuleLoader.resolveSync (node:internal/modules/esm/loader:766:56)
    at #resolve (node:internal/modules/esm/loader:690:17)
    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:610:35)
    at ModuleJob.syncLink (node:internal/modules/esm/module_job:277:33)
    at ModuleJob.link (node:internal/modules/esm/module_job:389:17) {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///app/types'
}

Node.js v26.7.0
node type-as-value.ts   # exits 1
file:///app/type-as-value.ts:1
import { Order, currency } from "./types.ts";
         ^^^^^
SyntaxError: The requested module './types.ts' does not provide an export named 'Order'
    at #asyncInstantiate (node:internal/modules/esm/module_job:463:21)
    at async ModuleJob.run (node:internal/modules/esm/module_job:561:5)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0
node right.ts
{ id: 1, total: 1250 } GBP

Two rules, both because Node reads each file on its own and does no guessing. First, write the extension: "./types.ts", exactly as ES modules need "./maths.mjs" (lesson 20). Second, import a type with import type. An interface is deleted from types.ts when it runs, so there is no Order left to import, and a plain import { Order } fails when the module is linked. import type is deleted along with the other types, so it never asks.

TypeScript that is not just types

A parameter property

dies with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX

account.ts

class Account {
  constructor(private balance: number) {}

  get total() {
    return this.balance;
  }
}

console.log(new Account(500).total);

What node printed as it died, when the build ran it

node account.ts

file:///app/account.ts:2
class Account {
  constructor(private balance: number) {}
                      ^^^^^^^^^^^^^^^

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode
    at parseTypeScript (node:internal/modules/typescript:57:40)
    at processTypeScriptCode (node:internal/modules/typescript:129:37)
    at stripTypeScriptModuleTypes (node:internal/modules/typescript:172:22)
    at Module._compile (node:internal/modules/cjs/loader:1888:15)
    at Object..js (node:internal/modules/cjs/loader:2074:10)
    at Module.load (node:internal/modules/cjs/loader:1656:32)
    at Module._load (node:internal/modules/cjs/loader:1448:12)
    at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
    at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
    at node:internal/main/run_main_module:33:47 {
  code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'
}

Node.js v26.7.0

A few TypeScript features are not annotations on JavaScript but generate JavaScript of their own. constructor(private balance: number) means "and assign this.balance = balance", which is code, and there is nothing to strip it down to. enum is the other one people meet (it is in the error catalogue), along with namespace. Node refuses all of them rather than guess. Each has a plain replacement:

An object as const, and a plain field

ran at build time

status.ts

// Instead of: enum Status { Active = "active", Suspended = "suspended" }
const Status = { Active: "active", Suspended: "suspended" } as const;
type Status = (typeof Status)[keyof typeof Status];

// Instead of a parameter property: a field and an assignment.
class Account {
  #balance: number;
  constructor(balance: number) {
    this.#balance = balance;
  }
  get total(): number {
    return this.#balance;
  }
}

function describe(status: Status): string {
  return status === Status.Active ? "can log in" : "cannot log in";
}

console.log(describe(Status.Active), "/", describe(Status.Suspended));
console.log("balance:", new Account(500).total);

check("the replacements run, with no generated code", [describe(Status.Suspended), new Account(500).total], ["cannot log in", 500]);

What node printed, when the build ran it

node status.ts

can log in / cannot log in
balance: 500
ok   the replacements run, with no generated code

Checking the types is a separate step

tsconfig.json for type stripping

shown, not run
// tsconfig.json -- settings that match what Node does
{
  "compilerOptions": {
    "module": "nodenext",
    "target": "esnext",
    "strict": true,
    "noEmit": true,                        // Node runs the .ts files; tsc only checks them
    "allowImportingTsExtensions": true,    // import "./types.ts"
    "verbatimModuleSyntax": true,          // insists on import type
    "erasableSyntaxOnly": true             // refuses enum and parameter properties, as Node does
  }
}

// and then, in package.json:
//   "scripts": { "check": "tsc", "test": "node --test" }
// run with:  npm install -D typescript  &&  npm run check

The type checker, tsc, is the typescript package from the npm registry, and this site installs nothing from it. Node itself only strips types, which every other panel on this page shows running.

So a TypeScript project on Node has two steps that used to be one. node runs the code, straight from the .ts files, fast. tsc, the TypeScript compiler, checks the types and writes nothing, and runs where you would run a linter: in your editor as you type, and in CI before anything is merged. The three settings at the bottom make tsc reject exactly the things Node would, so a program that passes the check also runs.

A package that ships .ts files

ran at build time

node_modules/money/package.json

{ "name": "money", "type": "module", "exports": "./index.ts" }

node_modules/money/index.ts

export const pounds = (pence: number): string => (pence / 100).toFixed(2);

app.ts

import { pounds } from "money";

console.log(pounds(1250));

The terminal, as the build ran it

node app.ts   # exits 1
node:internal/modules/typescript:155
    throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
          ^

Error [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]: Stripping types is currently unsupported for files under node_modules, for "file:///app/node_modules/money/index.ts"
    at stripTypeScriptModuleTypes (node:internal/modules/typescript:155:11)
    at ModuleLoader.<anonymous> (node:internal/modules/esm/translators:681:29)
    at #translate (node:internal/modules/esm/loader:441:20)
    at afterLoad (node:internal/modules/esm/loader:509:29)
    at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:514:12)
    at #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:567:36)
    at afterResolve (node:internal/modules/esm/loader:614:52)
    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:620:12)
    at ModuleJob.syncLink (node:internal/modules/esm/module_job:277:33)
    at ModuleJob.link (node:internal/modules/esm/module_job:389:17) {
  code: 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'
}

Node.js v26.7.0

One limit: Node strips types only in your own files, never in node_modules. A package has to be published as JavaScript, with its types in separate .d.ts files, so that it works for everyone whatever they run it with. Lesson 49 builds a package.

  • node file.ts runs TypeScript by deleting the types. It never checks them.
  • Types are replaced with spaces, so line and column numbers stay correct.
  • Import with the .ts extension, and import types with import type.
  • enum, namespace and parameter properties are refused. Use an object as const and ordinary fields.
  • Check types separately with tsc --noEmit, and validate outside data at run time anyway.

48 Configuration, logging and graceful shutdown

A program that works is not yet a service. A service is started by something else — a container platform, a process manager — with settings it did not choose, runs for weeks, writes logs that someone reads when it goes wrong at three in the morning, and is stopped and replaced every time you deploy. Three habits make that go smoothly, and each of them is a few lines of plain Node.

Configuration: check it all at start-up

loadConfig, refusing to start, then starting

ran at build time

config.mjs

export class ConfigError extends Error {}

// Read, check and convert every setting once, at start-up.
export function loadConfig(env) {
  const problems = [];

  const port = Number(env.PORT ?? 3000);
  if (!Number.isInteger(port) || port < 1 || port > 65535) problems.push(`PORT must be a port number, got "${env.PORT}"`);

  const logLevel = env.LOG_LEVEL ?? "info";
  if (!["debug", "info", "warn", "error"].includes(logLevel)) problems.push(`LOG_LEVEL must be debug, info, warn or error, got "${logLevel}"`);

  const apiToken = env.API_TOKEN;
  if (!apiToken) problems.push("API_TOKEN is not set");

  if (problems.length > 0) throw new ConfigError(problems.join("\n"));
  return Object.freeze({ port, logLevel, apiToken });
}

main.mjs

import { loadConfig, ConfigError } from "./config.mjs";

let config;
try {
  config = loadConfig(process.env);
} catch (err) {
  if (!(err instanceof ConfigError)) throw err;
  console.error("cannot start, the configuration is wrong:");
  console.error(err.message.replace(/^/gm, "  - "));
  process.exit(1);
}

console.log("starting with", { ...config, apiToken: `[set, ${config.apiToken.length} characters]` });
// ... and the server would start here.

The terminal, as the build ran it

node main.mjs   # exits 1
cannot start, the configuration is wrong:
  - API_TOKEN is not set
PORT=80a LOG_LEVEL=verbose node main.mjs   # exits 1
cannot start, the configuration is wrong:
  - PORT must be a port number, got "80a"
  - LOG_LEVEL must be debug, info, warn or error, got "verbose"
  - API_TOKEN is not set
PORT=8080 API_TOKEN=pretend-token-123 node main.mjs
starting with { port: 8080, logLevel: 'info', apiToken: '[set, 17 characters]' }

Settings that change between your laptop and production — ports, addresses, tokens — come from environment variables (lesson 31), and loadConfig is the one place that reads them. It converts each one to the right type, checks it, and collects every problem before refusing to start, so a broken deployment fails immediately, with a message listing everything to fix. The alternative, reading process.env.API_TOKEN wherever it is needed, fails an hour later on the first request that needs the token, with an error that mentions undefined.

The result is frozen, so nothing can change it by accident, and it is passed to the rest of the program rather than read again. And the start-up line shows that the token was set and how long it is, never the token itself: anything printed ends up in a log, and logs are read by far more people, and kept far longer, than secrets should be.

Logs a machine can read

JSON lines, levels, redaction and a request id

ran at build time

logger.mjs

import { AsyncLocalStorage } from "node:async_hooks";

const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
const SECRET = /password|token|secret|authorization/i;

// Whatever is stored here is visible to all code running on behalf of one request.
export const requestContext = new AsyncLocalStorage();

export function createLogger(level = "info") {
  const minimum = LEVELS[level];
  const write = (lvl, msg, fields = {}) => {
    if (LEVELS[lvl] < minimum) return;
    const entry = { level: lvl, msg, ...requestContext.getStore(), ...fields };
    console.log(JSON.stringify(entry, (key, value) => (SECRET.test(key) ? "[redacted]" : value)));
  };
  return {
    debug: (msg, fields) => write("debug", msg, fields),
    info: (msg, fields) => write("info", msg, fields),
    warn: (msg, fields) => write("warn", msg, fields),
    error: (msg, fields) => write("error", msg, fields),
  };
}

example.mjs

import { createServer } from "node:http";
import { once } from "node:events";
import { setTimeout as sleep } from "node:timers/promises";
import { createLogger, requestContext } from "./logger.mjs";

const log = createLogger("info");                  // debug lines are dropped at this level
const prices = { tea: 350, cake: 275, soup: 590 };
const slowness = { tea: 30, cake: 60, soup: 90 };   // ms the pretend database takes

// Deep inside the program, with no idea which request it is serving.
async function lookUpPrice(product) {
  log.debug("looking up a price", { product });
  await sleep(slowness[product]);                   // pretend to ask a database
  log.info("price found", { product, pence: prices[product] });
  return prices[product];
}

let nextId = 1;
const server = createServer((req, res) => {
  const requestId = `req-${nextId++}`;
  requestContext.run({ requestId }, async () => {
    log.info("request started", { path: req.url, authorization: req.headers.authorization });
    const product = req.url.split("/").pop();
    const pence = await lookUpPrice(product);
    res.end(String(pence));
    log.info("request finished", { status: 200 });
  });
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;

// Three requests at once, so their log lines interleave. (The lines are also
// collected here, so the checks below can look at them.)
const lines = [];
const realLog = console.log;
console.log = (line) => { lines.push(JSON.parse(line)); realLog(line); };
await Promise.all(["soup", "tea", "cake"].map((product) =>
  fetch(`${base}/prices/${product}`, { headers: { authorization: "Bearer pretend-token-123" } })));
server.close();
console.log = realLog;

const idFor = Object.fromEntries(lines.filter((l) => l.path).map((l) => [l.path.split("/").pop(), l.requestId]));
const found = lines.filter((l) => l.msg === "price found");
check("the lines of the three requests interleaved", found.map((l) => l.product), ["tea", "cake", "soup"]);
check("yet each line from lookUpPrice carries the id of its own request", found.every((l) => l.requestId === idFor[l.product]), true);
check("debug lines were dropped, and the token never printed",
      [lines.some((l) => l.level === "debug"), lines.filter((l) => l.path).every((l) => l.authorization === "[redacted]")], [false, true]);

What node printed, when the build ran it

node example.mjs

{"level":"info","msg":"request started","requestId":"req-1","path":"/prices/soup","authorization":"[redacted]"}
{"level":"info","msg":"request started","requestId":"req-2","path":"/prices/tea","authorization":"[redacted]"}
{"level":"info","msg":"request started","requestId":"req-3","path":"/prices/cake","authorization":"[redacted]"}
{"level":"info","msg":"price found","requestId":"req-2","product":"tea","pence":350}
{"level":"info","msg":"request finished","requestId":"req-2","status":200}
{"level":"info","msg":"price found","requestId":"req-3","product":"cake","pence":275}
{"level":"info","msg":"request finished","requestId":"req-3","status":200}
{"level":"info","msg":"price found","requestId":"req-1","product":"soup","pence":590}
{"level":"info","msg":"request finished","requestId":"req-1","status":200}
ok   the lines of the three requests interleaved
ok   yet each line from lookUpPrice carries the id of its own request
ok   debug lines were dropped, and the token never printed

Each line is one JSON object: easy for a person to read, and trivial for a log system to search — "every error line for req-3" is a query rather than a squint. Levels let a production server log at info and hide the debug detail you only want while investigating. The replacer passed to JSON.stringify redacts any field whose name looks secret, so the authorization header, logged carelessly in "request started", came out as [redacted].

The request id is the interesting part. Three requests were in flight at once, so their lines interleaved, and lookUpPrice was never told which request it was serving — yet every one of its lines carries the right id. AsyncLocalStorage keeps a value for everything that runs because of one run() call, across every await and callback that follows from it, the way a thread-local variable would in a language with a thread per request. Put the id in at the edge of the program, and every log line and error from anywhere below includes it.

Log to standard output, one line per event, and let whatever runs the process collect it. A service that writes and rotates its own log files is doing the platform's job, badly. In real projects a package such as pino does all of this, faster; it produces the same kind of line.

Graceful shutdown

Deploying a new version means stopping the old one, and a platform stops a process by sending it SIGTERM (lesson 31), then, if it has not gone after some seconds, SIGKILL, which cannot be caught. The panel runs one server three ways. Each time, a slow request is in flight when the signal arrives, and a second request is sent just after it.

SIGTERM, three ways

ran at build time

server.mjs

import { createServer } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";

const mode = process.env.SHUTDOWN;         // "none", "close" or "graceful"
let shuttingDown = false;

const server = createServer(async (req, res) => {
  await sleep(300);                                            // a slow request
  if (shuttingDown && mode === "graceful") {
    res.setHeader("connection", "close");                      // do not keep this connection
  }
  res.end(`finished ${req.url}`);
});

if (mode !== "none") {
  process.on("SIGTERM", () => {
    shuttingDown = true;
    server.close(() => process.exit(0));                       // stop accepting; exit once idle
    if (mode === "graceful") server.closeIdleConnections();    // and drop idle keep-alives now
    setTimeout(() => process.exit(1), 10_000).unref();         // but never wait forever
  });
}

server.listen(0, "127.0.0.1", () => console.log(`http://127.0.0.1:${server.address().port}`));

example.mjs

import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { once } from "node:events";
import { setTimeout as sleep } from "node:timers/promises";

async function tryShutdown(mode) {
  const server = spawn(process.execPath, ["server.mjs"], {
    env: { ...process.env, SHUTDOWN: mode },
    stdio: ["ignore", "pipe", "inherit"],
  });
  const exited = once(server, "exit");
  const [base] = await once(createInterface({ input: server.stdout }), "line");

  const inFlight = fetch(`${base}/order`).then(
    async (res) => `${res.status} ${await res.text()}`,
    (err) => `failed (${err.cause?.code})`);
  await sleep(100);                                  // the request is now in progress

  const signalled = performance.now();
  server.kill("SIGTERM");
  await sleep(20);
  const late = await fetch(`${base}/late`).then((res) => String(res.status), (err) => `failed (${err.cause?.code})`);
  const [code, signal] = await exited;

  return {
    mode,
    "in flight": await inFlight,
    "sent after": late,
    "ended by": signal ?? `exit ${code}`,
    "ms to exit": Math.round(performance.now() - signalled),
  };
}

const results = [];
for (const mode of ["none", "close", "graceful"]) results.push(await tryShutdown(mode));
console.table(results);

const [none, close, graceful] = results;
check("with no handler, the request in flight was cut off", [none["in flight"].startsWith("failed"), none["ended by"]], [true, "SIGTERM"]);
check("close() let it finish, but the process lingered for seconds", [close["in flight"], close["ms to exit"] > 1000], ["200 finished /order", true]);
check("the graceful version finished it and left promptly", [graceful["in flight"], graceful["ended by"], graceful["ms to exit"] < 1000], ["200 finished /order", "exit 0", true]);
check("no version accepted a request after the signal", results.every((r) => r["sent after"] === "failed (ECONNREFUSED)"), true);

What node printed, when the build ran it

node example.mjs

┌─────────┬────────────┬───────────────────────────┬─────────────────────────┬───────────┬────────────┐
│ (index) │ mode       │ in flight                 │ sent after              │ ended by  │ ms to exit │
├─────────┼────────────┼───────────────────────────┼─────────────────────────┼───────────┼────────────┤
│ 0       │ 'none'     │ 'failed (UND_ERR_SOCKET)' │ 'failed (ECONNREFUSED)' │ 'SIGTERM' │ 24         │
│ 1       │ 'close'    │ '200 finished /order'     │ 'failed (ECONNREFUSED)' │ 'exit 0'  │ 3222       │
│ 2       │ 'graceful' │ '200 finished /order'     │ 'failed (ECONNREFUSED)' │ 'exit 0'  │ 215        │
└─────────┴────────────┴───────────────────────────┴─────────────────────────┴───────────┴────────────┘
ok   with no handler, the request in flight was cut off
ok   close() let it finish, but the process lingered for seconds
ok   the graceful version finished it and left promptly
ok   no version accepted a request after the signal

With no handler, SIGTERM ended the process on the spot, and the customer whose order was in flight got a broken connection. server.close() is half the fix: it stops accepting new connections at once (the late request was refused) and calls back when the existing ones are done, so the order finished. But the process then lingered for seconds, because the client kept its connection open for reuse — HTTP keep-alive — and an open connection counts as not done. The graceful version also closes idle connections immediately and tells clients with requests still running not to reuse theirs (connection: close), so it exits the moment the last request is answered. The unreferenced ten-second timer is the safety net: if something never finishes, exit anyway rather than wait for SIGKILL.

A real shutdown handler then closes whatever else is open, in reverse order of opening: stop accepting work, finish what is in flight, close the database (lesson 39), flush the logs, exit 0.

In a container, handle SIGTERM yourself. Run as CMD ["node", "server.mjs"], Node is process 1 in the container, and Linux gives process 1 no default signal behaviour: without a handler, SIGTERM does nothing at all, and the container sits there until it is killed ten seconds later. A handler like the one above fixes that, as does running with docker run --init.

The last line of defence

shown, not run
// When something is truly broken, log it and exit -- and let the platform restart you.
process.on("uncaughtException", (err) => {
  log.error("uncaught exception, exiting", { error: err.stack });
  process.exit(1);
});
process.on("unhandledRejection", (reason) => {
  log.error("unhandled rejection, exiting", { error: String(reason) });
  process.exit(1);
});

A last-resort handler only matters in a process that has already gone wrong; lesson 28 ran one for real. This is its shape in a service, next to the logger above.

After an uncaught exception the program is in a state nobody planned for, so do not try to carry on: log it, exit non-zero, and let the process manager — systemd, a container restart policy, Kubernetes — start a fresh copy. That is only safe because the rest of this lesson is in place: configuration is checked on every start, and a restart drops nothing the graceful path would have kept.

  • Read configuration from the environment once, validate all of it, and refuse to start if it is wrong.
  • Never print secrets. Redact them in logs by field name.
  • Log one JSON object per line to standard output, with a level. Use AsyncLocalStorage to attach a request id everywhere.
  • On SIGTERM: stop accepting, finish in-flight requests, close idle connections, clean up, exit — with a timeout.
  • On an uncaught error: log, exit 1, and let the platform restart you.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.