Module 01Node.jseasy14 min

Module 1 — The Skeleton That Runs

The smallest useful backend isn't 'hello world.' It's a process that starts predictably, tells you it's healthy, and stops without dropping requests. Get the lifecycle right first and every feature lands on solid ground.

We start with the machine, exactly like The Runtime chapter. Not a feature — a process. The goal of this module is a server you can start, ask "are you okay?", and stop cleanly. That last part, graceful shutdown, is the thing tutorials never show and production always needs.

Goal

A TypeScript HTTP server that:

  • starts and listens on a port from the environment,
  • answers GET /health with 200 and a tiny JSON body,
  • reads its config from environment variables (no hardcoding),
  • and shuts down gracefully on SIGTERM / SIGINT.

Step 1: Project skeleton

Initialise a TypeScript Node project. You want type: "module" (modern ESM), TypeScript, and a small web framework. We'll use Hono because it's tiny and runtime-agnostic, but Fastify or Express work identically for this module.

mkdir snippets && cd snippets
npm init -y
npm i hono @hono/node-server
npm i -D typescript tsx @types/node
npx tsc --init

Set "type": "module" in package.json and add a dev script: "dev": "tsx watch src/server.ts".

Step 2: Config from the environment, validated

The chapter's rule: config comes from the environment, and you validate it once at startup so a misconfigured server fails immediately and loudly, not on the first request an hour later.

// src/config.ts
function required(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`Missing required env var: ${name}`);
  return v;
}

export const config = {
  port: Number(process.env.PORT ?? 3000),
  // DATABASE_URL is required from Module 2 on; we read it now so a
  // misconfigured deploy crashes at boot, not mid-request.
  databaseUrl: process.env.DATABASE_URL ?? "",
  nodeEnv: process.env.NODE_ENV ?? "development",
};

Why validate config at boot

A server that reads a missing env var lazily will run fine until the first request that needs it, then 500 in production looking like a code bug. Validating at startup turns a confusing runtime failure into an obvious boot-time crash with a clear message — fail fast, fail loud, fail where it's cheap to notice.

Step 3: The server and a health check

// src/server.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { config } from "./config.js";

const app = new Hono();

app.get("/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));

const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
  console.log(`listening on :${info.port}`);
});

The health check seems trivial but it's load-bearing: your load balancer and your container orchestrator will poll it to decide whether to send traffic to this instance. A real health check eventually also checks "can I reach the database?" — we'll come back to it in Module 7.

Step 4: Graceful shutdown (the part that matters)

When your platform deploys a new version, it sends your old process a SIGTERM and expects it to exit. If you exit immediately, any request currently being handled is cut off mid-flight — the user gets a dropped connection, a half-written response, maybe a half-committed transaction. Graceful shutdown means: stop accepting new connections, let the in-flight ones finish, then exit.

  1. Catch the signal

    Listen for SIGTERM (from the orchestrator) and SIGINT (Ctrl-C in dev).

  2. Stop accepting new connections

    Close the server's listener so the load balancer stops routing new requests here, while existing ones keep running.

  3. Drain, then exit

    Wait for in-flight requests to finish (with a timeout, so a stuck request can't block shutdown forever), close the database pool, then process.exit(0).

function shutdown(signal: string) {
  console.log(`${signal} received, draining…`);
  server.close(() => {
    console.log("closed; exiting");
    process.exit(0);
  });
  // Safety net: don't hang forever on a stuck connection.
  setTimeout(() => {
    console.error("drain timed out, forcing exit");
    process.exit(1);
  }, 10_000).unref();
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

The bug graceful shutdown prevents

Without this, every single deploy drops a handful of requests — the ones unlucky enough to be in flight when the old process was killed. At low traffic you might never notice; at scale it's a steady trickle of mysterious errors that spike exactly at deploy time and that nobody can reproduce, because the cause is the deploy, not the code. The fix is this twenty lines, and it's why graceful shutdown is in the runtime chapter, not an afterthought.

Acceptance check

npm run dev
# in another terminal:
curl localhost:3000/health      # → {"status":"ok","uptime":...}

# now test graceful shutdown: hit the server, then Ctrl-C the dev process.
# you should see "SIGINT received, draining…" then "closed; exiting" —
# NOT an instant death.

You're done when /health returns 200 and stopping the server prints the drain message instead of dying instantly. Commit it.

What you just internalised

A backend is a long-lived process with a lifecycle, not a script that runs once. Starting predictably (validated config), reporting health (the load balancer's hook), and stopping cleanly (graceful drain) are the three lifecycle events every service needs before it needs a single feature. Everything from here hangs off this skeleton.

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…