Build an HTTP Server with Graceful Shutdown
An HTTP service that drains in-flight requests on shutdown, stops accepting new connections, runs cleanup hooks, and exits within a bounded timeout, documented with tests.
Project goal
Build an HTTP server that on SIGTERM stops accepting connections, waits for in-flight requests up to a deadline, runs registered cleanup hooks, and exits cleanly without dropping live requests.
Final features
- Stop accepting new connections the moment shutdown begins.
- Let in-flight requests finish within a configurable grace period.
- Run registered cleanup hooks such as closing database handles.
- Force exit after the deadline so a stuck request cannot hang the process.
- Expose a readiness endpoint that flips to unhealthy during drain.
Architecture
A request tracker counts active requests; a lifecycle module owns the shutdown state machine from running to draining to closed; a shutdown registry holds cleanup hooks; and the server wires these to process signals. The state machine is testable without real signals by calling its transitions directly.
Prerequisites
- Node.js 20 LTS or later
- Understanding of process signals and the event loop
- Familiarity with HTTP servers and request lifetimes
Dependencies
- node >=20 — node:http, process signals, and timers
- vitest ^2.1.0 — State machine and end-to-end drain tests
Directory structure
graceful-server/
├── src/
│ ├── tracker.js
│ ├── lifecycle.js
│ ├── hooks.js
│ ├── server.js
│ └── cli.js
├── test/
│ ├── lifecycle.test.js
│ └── drain.test.js
└── package.json
Phases
1. Request tracking
Count active requests and expose a hook to wrap handlers so the count is accurate under concurrency.
Files: src/tracker.js
export function createTracker() {
let active = 0;
return {
active: () => active,
track(handler) {
return async (...args) => {
active += 1;
try { return await handler(...args); }
finally { active -= 1; }
};
},
};
}Expected behavior: The active count returns to zero after every tracked request, including when a handler throws.
2. Lifecycle state machine
Model running, draining, closing, and closed states with an explicit drain and close transition.
Files: src/lifecycle.js
export function waitFor(predicate, timeoutMs, step = 25) {
return new Promise((resolve) => {
const started = Date.now();
const tick = () => {
if (predicate()) return resolve(true);
if (Date.now() - started >= timeoutMs) return resolve(false);
setTimeout(tick, step);
};
tick();
});
}
export function createLifecycle({ deadlineMs = 10000 } = {}) {
let state = 'running';
return {
get state() { return state; },
beginDrain() { if (state === 'running') state = 'draining'; },
async close(active) {
state = 'closing';
const drained = await waitFor(() => active() === 0, deadlineMs);
state = 'closed';
return { drained };
},
};
}Expected behavior: Close resolves with drained true when the active count reaches zero, and false once the deadline passes.
3. Cleanup hooks
Register cleanup hooks and run them in order during close, ensuring they run even if the drain times out.
Files: src/hooks.js
export function createHookRegistry() {
const hooks = [];
return {
register(name, fn) { hooks.push({ name, fn }); },
async runAll() {
const ran = [];
for (const hook of hooks) {
await hook.fn();
ran.push(hook.name);
}
return ran;
},
};
}Expected behavior: Hooks run in registration order, each awaited, and a hook that throws stops the sequence with that error surfaced.
4. Server wiring
Build the HTTP server with tracking, a readiness endpoint, and signal handlers that begin drain before closing.
Files: src/server.js
import { createServer } from 'node:http';
import { createTracker } from './tracker.js';
import { createLifecycle } from './lifecycle.js';
import { createHookRegistry } from './hooks.js';
export function createApp({ deadlineMs = 10000, delayMs = 0 } = {}) {
const tracker = createTracker();
const lifecycle = createLifecycle({ deadlineMs });
const hooks = createHookRegistry();
const handle = tracker.track(async (req, res) => {
if (req.url === '/readyz') {
return send(res, lifecycle.state === 'running' ? 200 : 503, { state: lifecycle.state });
}
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
send(res, 200, { ok: true });
});
const server = createServer((req, res) => {
if (lifecycle.state !== 'running') return send(res, 503, { error: 'draining' });
return handle(req, res);
});
return { server, lifecycle, hooks, tracker };
}
function send(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}Expected behavior: While running the readiness endpoint returns 200, after drain begins it returns 503, and a request during drain is refused with 503.
5. Signal handling and exit
On SIGTERM begin drain, stop listening, wait for in-flight work, run hooks, and exit with the right code.
Files: src/cli.js
import { createApp } from './server.js';
const app = createApp({
deadlineMs: Number(process.env.GRACE_MS ?? 10000),
delayMs: Number(process.env.DELAY_MS ?? 0),
});
app.hooks.register('close-db', async () => { /* release handles here */ });
app.server.listen(process.env.PORT ?? 3000, () => console.log('listening'));
async function shutdown(signal) {
console.log('received ' + signal + ', draining');
app.lifecycle.beginDrain();
app.server.close();
const result = await app.lifecycle.close(app.tracker.active);
await app.hooks.runAll();
console.log(result.drained ? 'drained cleanly' : 'deadline reached, forcing exit');
process.exit(result.drained ? 0 : 1);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));Expected behavior: Sending SIGTERM lets an in-flight slow request finish, runs the cleanup hook, and exits zero, or exits one if the deadline is exceeded.
6. Drain tests
Start the server, issue a slow request, trigger drain, and assert the request completed and later requests were refused.
Files: test/drain.test.js
import { createApp } from '../src/server.js';
test('in-flight request completes during drain and new ones are refused', async () => {
const app = createApp({ deadlineMs: 2000, delayMs: 100 });
await new Promise((r) => app.server.listen(0, r));
const port = app.server.address().port;
const inflight = fetch('http://localhost:' + port + '/work').then((r) => r.status);
app.lifecycle.beginDrain();
app.server.close();
const late = await fetch('http://localhost:' + port + '/work').catch(() => 'refused');
expect(await inflight).toBe(200);
expect(late === 'refused' || late === 503).toBe(true);
});Expected behavior: The in-flight request resolves 200 while a request issued after drain begins is either refused or answered 503.
Testing
Run npx vitest. Lifecycle tests call beginDrain and close directly and assert the state sequence and hook execution order. A drain test starts the server, fires a slow request, triggers shutdown, and asserts the request completes while a request initiated after draining starts is refused.
Troubleshooting
The process never exits after close
Call server.close to stop the listener and clear any lingering timers or open handles; the event loop stays alive until every handle is released.
Requests are dropped on deploy
Begin draining before closing the listener and flip readiness to unhealthy first; the load balancer needs the unhealthy signal before it stops routing new traffic.
A stuck request blocks shutdown past the grace period
Bound the wait with a deadline and exit with a failure code if it expires; an unbounded wait turns a deploy into an outage.
Deployment
Set the grace period slightly longer than the orchestrator termination grace so the process exits before the platform sends SIGKILL. Ensure the readiness endpoint is what the load balancer checks, so drain actually removes the instance from rotation.
Known limitations
- Cleanup hooks are best-effort; a hook that hangs still delays exit until the deadline.
- The readiness state is in-process, so a multi-instance deployment relies on the platform polling each instance.
Project overview
The difference between a service that restarts cleanly and one that corrupts work is what happens in the seconds after a termination signal. This project models that window explicitly: stop intake, drain, run hooks, then exit or force.
The server tracks in-flight requests, flips a readiness flag as soon as draining begins, waits for the active count to reach zero before the deadline, and always runs cleanup.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
The lifecycle is a small state machine whose transitions are called directly in tests, which is what makes shutdown behaviour deterministic:
export function createLifecycle({ deadlineMs = 10000 } = {}) {
let state = 'running';
const hooks = [];
return {
get state() { return state; },
beginDrain() { if (state === 'running') state = 'draining'; },
registerHook(fn) { hooks.push(fn); },
async close(active) {
state = 'closing';
await waitFor(() => active() === 0, deadlineMs);
for (const hook of hooks) await hook();
state = 'closed';
return { drained: active() === 0 };
},
};
}
Because the transition to draining is separate from the wait, the readiness endpoint can report unhealthy before any request is dropped.
Setup
The service uses only the standard library so the shutdown logic is the only moving part worth reading.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | node:http, process signals, and timers |
| vitest | ^2.1.0 | State machine and end-to-end drain tests |
Create the project and install:
mkdir graceful-server && cd graceful-server
npm init -y
npm install -D vitest@^2.1.0
Configure the server entry and tests in package.json:
{
"name": "graceful-server",
"type": "module",
"scripts": {
"start": "node src/cli.js",
"test": "vitest run"
}
}
Set the grace period from an environment variable so the test uses a short deadline and production uses a realistic one.
Phase 1: Request tracking
Count active requests and expose a hook to wrap handlers so the count is accurate under concurrency. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
export function createTracker() {
let active = 0;
return {
active: () => active,
track(handler) {
return async (...args) => {
active += 1;
try { return await handler(...args); }
finally { active -= 1; }
};
},
};
}
Acceptance for this phase: The active count returns to zero after every tracked request, including when a handler throws.
Phase 2: Lifecycle state machine
Model running, draining, closing, and closed states with an explicit drain and close transition. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
export function waitFor(predicate, timeoutMs, step = 25) {
return new Promise((resolve) => {
const started = Date.now();
const tick = () => {
if (predicate()) return resolve(true);
if (Date.now() - started >= timeoutMs) return resolve(false);
setTimeout(tick, step);
};
tick();
});
}
export function createLifecycle({ deadlineMs = 10000 } = {}) {
let state = 'running';
return {
get state() { return state; },
beginDrain() { if (state === 'running') state = 'draining'; },
async close(active) {
state = 'closing';
const drained = await waitFor(() => active() === 0, deadlineMs);
state = 'closed';
return { drained };
},
};
}
Acceptance for this phase: Close resolves with drained true when the active count reaches zero, and false once the deadline passes.
Phase 3: Cleanup hooks
Register cleanup hooks and run them in order during close, ensuring they run even if the drain times out. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
export function createHookRegistry() {
const hooks = [];
return {
register(name, fn) { hooks.push({ name, fn }); },
async runAll() {
const ran = [];
for (const hook of hooks) {
await hook.fn();
ran.push(hook.name);
}
return ran;
},
};
}
Acceptance for this phase: Hooks run in registration order, each awaited, and a hook that throws stops the sequence with that error surfaced.
Phase 4: Server wiring
Build the HTTP server with tracking, a readiness endpoint, and signal handlers that begin drain before closing. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
import { createServer } from 'node:http';
import { createTracker } from './tracker.js';
import { createLifecycle } from './lifecycle.js';
import { createHookRegistry } from './hooks.js';
export function createApp({ deadlineMs = 10000, delayMs = 0 } = {}) {
const tracker = createTracker();
const lifecycle = createLifecycle({ deadlineMs });
const hooks = createHookRegistry();
const handle = tracker.track(async (req, res) => {
if (req.url === '/readyz') {
return send(res, lifecycle.state === 'running' ? 200 : 503, { state: lifecycle.state });
}
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
send(res, 200, { ok: true });
});
const server = createServer((req, res) => {
if (lifecycle.state !== 'running') return send(res, 503, { error: 'draining' });
return handle(req, res);
});
return { server, lifecycle, hooks, tracker };
}
function send(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
Acceptance for this phase: While running the readiness endpoint returns 200, after drain begins it returns 503, and a request during drain is refused with 503.
Phase 5: Signal handling and exit
On SIGTERM begin drain, stop listening, wait for in-flight work, run hooks, and exit with the right code. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
import { createApp } from './server.js';
const app = createApp({
deadlineMs: Number(process.env.GRACE_MS ?? 10000),
delayMs: Number(process.env.DELAY_MS ?? 0),
});
app.hooks.register('close-db', async () => { /* release handles here */ });
app.server.listen(process.env.PORT ?? 3000, () => console.log('listening'));
async function shutdown(signal) {
console.log('received ' + signal + ', draining');
app.lifecycle.beginDrain();
app.server.close();
const result = await app.lifecycle.close(app.tracker.active);
await app.hooks.runAll();
console.log(result.drained ? 'drained cleanly' : 'deadline reached, forcing exit');
process.exit(result.drained ? 0 : 1);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Acceptance for this phase: Sending SIGTERM lets an in-flight slow request finish, runs the cleanup hook, and exits zero, or exits one if the deadline is exceeded.
Phase 6: Drain tests
Start the server, issue a slow request, trigger drain, and assert the request completed and later requests were refused. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { createApp } from '../src/server.js';
test('in-flight request completes during drain and new ones are refused', async () => {
const app = createApp({ deadlineMs: 2000, delayMs: 100 });
await new Promise((r) => app.server.listen(0, r));
const port = app.server.address().port;
const inflight = fetch('http://localhost:' + port + '/work').then((r) => r.status);
app.lifecycle.beginDrain();
app.server.close();
const late = await fetch('http://localhost:' + port + '/work').catch(() => 'refused');
expect(await inflight).toBe(200);
expect(late === 'refused' || late === 503).toBe(true);
});
Acceptance for this phase: The in-flight request resolves 200 while a request issued after drain begins is either refused or answered 503.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Lifecycle tests call beginDrain and close directly and assert the state sequence and hook execution order. A drain test starts the server, fires a slow request, triggers shutdown, and asserts the request completes while a request initiated after draining starts is refused.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The concurrency test is the meaningful one: it proves the server waits for exactly the requests it accepted and turns away the ones it did not, which is the contract that avoids dropped work.
Extending the project
A second signal for immediate shutdown, per-route drain priorities, and a health detail payload are all additions around the same state machine. Because hooks are registered rather than hard-coded, new resources can join cleanup without touching the lifecycle.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.