gap·map

Node server patterns

[ middle depth ]

How Express middleware runs

A middleware is a function (req, res, next). Express runs them in the order you register them, top to bottom. Each one has two legal exits: call next() to hand off to the next middleware, or send a response to end the chain. Do neither and the request hangs until it times out.

Order is the whole game, and most middleware bugs are order bugs. Mount express.json() after a route that reads req.body and the body is undefined. Mount an auth guard after the handler it was supposed to protect and it guards nothing.

app.use(express.json());       // body parser first
app.use(requireAuth);          // then the guard
app.get("/orders", listOrders); // then the handler

How to catch errors from async handlers

Error-handling middleware has four arguments: (err, req, res, next). Express identifies it by that arity, not by name or position label, and you register it last so it sits at the bottom of the chain.

The trap is async. In Express 4, the error handler only sees errors you pass to next(err) or throw synchronously. A rejected promise inside an async route is invisible to it:

// Express 4: this rejection never reaches the error handler
app.get("/u/:id", async (req, res) => {
  const u = await db.getUser(req.params.id); // if this rejects, request hangs
  res.json(u);
});

Wrong "the error middleware catches everything, so I do not need try/catch." It catches what reaches next(err). In Express 4 you wrap the handler or try/catch and call next(err) yourself. Express 5 changed this: a handler that returns a rejecting promise (an async function does) is forwarded to next(err) for you. Errors thrown from a detached callback like setTimeout are still on you in both versions.

Reading config from the environment

Config that changes between dev, staging, and prod lives in the environment, not in the code. That is the twelve-factor rule: one build, many deployments, differing only by env vars. Read process.env once at boot, validate it, and fail fast if a required value is missing so a bad deploy dies at startup instead of on a live request.

Shutting down without dropping requests

When the platform stops your container it sends SIGTERM. Catch it, stop taking new work, let in-flight requests finish, then exit:

process.on("SIGTERM", () => {
  server.close(() => {
    db.end().then(() => process.exit(0));
  });
});

server.close() stops accepting new connections and waits for active requests to complete. The senior notes cover why this simple version can still hang.

[ senior depth ]

The graceful-shutdown sequence, in order

Zero-dropped-requests shutdown is an ordering problem. On SIGTERM:

  1. Flip a shuttingDown flag so the readiness endpoint starts returning 503. The load balancer or k8s readiness probe sees that and stops routing new requests to this instance. Traffic drains before you touch the server.
  2. server.close() to stop the listener and wait for in-flight requests to finish.
  3. Reap idle keep-alive sockets, then drain dependencies: DB pool, queue consumers, open cursors.
  4. process.exit(0).
let shuttingDown = false;
process.on("SIGTERM", () => {
  if (shuttingDown) return;      // second SIGTERM must not re-enter
  shuttingDown = true;
  server.close(() => db.end().then(() => process.exit(0)));
  server.closeIdleConnections(); // Node 18.2+, drop sockets between requests
  setTimeout(() => process.exit(1), 10_000).unref(); // backstop
});

Two details interviewers listen for. The force-exit timer needs .unref() so the timer itself does not keep the event loop alive, otherwise it defeats a clean early exit. And the handler guards against re-entry, because orchestrators often send a second signal.

Why server.close() hangs

server.close() waits for every open connection to end, and an idle HTTP/1.1 keep-alive socket is open even though no request is on it. So the callback can block until keepAliveTimeout elapses, or indefinitely if the peer keeps the socket warm.

Wrong "server.close() closes all the sockets, so the process exits." It closes the listener and waits; it never force-closes a live connection. Set server.keepAliveTimeout, call server.closeIdleConnections() to drop the ones sitting between requests, and keep closeAllConnections() for the force case. Upgraded connections (websockets, SSE) are a separate teardown; closeIdleConnections leaves them alone.

Liveness vs readiness, on the app side

Two questions, two endpoints. Liveness answers "is this process wedged, should it be restarted." Keep it cheap and dependency-free: if /healthz pings the shared database and the database blips, every replica fails liveness at once and the orchestrator restarts healthy pods into a crash loop. Readiness answers "should I receive traffic right now," may check dependencies, and returns 503 during warmup and during the shutdown drain above. The orchestrator owns the probe cadence and restart policy; your job is what the handlers return.

What to do on uncaughtException

After an uncaughtException or unhandledRejection the process is in an undefined state. The Node docs are blunt that this is not On Error Resume Next. The only safe handler does synchronous cleanup (flush a log, close a file descriptor) and then exits non-zero so a supervisor restarts a clean process. Since Node 15 an unhandled rejection crashes by default, which is the behavior you want. Do not swallow these to keep a wounded process serving.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Node.js

was this useful?