Building a REST API

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out. Versions and stability levels were checked against the official documentation on 2026-10-04, and the complete project on this page was run, including its test suite, on Node.js 26 and 24, and on 22 with one third-party polyfill, as the text explains. The page builds the Bookshelf JSON API with nothing but what ships in Node.js: node:http (Stability 2, Stable) for the server, the URLPattern global (Stability 1, Experimental) for routing, node:sqlite (Stability 1.2, Release candidate) for storage, node:test (Stability 2, Stable) for end-to-end tests, and the platform fetch as the test client. There is no Express, no Fastify and no ORM, which is deliberate: after one pass through the built-ins every framework feature becomes recognisable, and Web Frameworks: Express & Fastify shows the same API with a framework doing the plumbing. The pieces were introduced on earlier pages: HTTP Server Fundamentals (requests, responses, streams), SQLite Built-in (the database API), Test Runner and Server Timeouts, Limits & Hardening. This page assembles them into one small but production-shaped service, and each section explains the design decision that makes it so. The running scenario is described in Introduction & Architecture.

The stability and version map

Table 1. APIs used on this page
API Stability (Node.js 26 docs) Version notes

node:http: createServer(), IncomingMessage, ServerResponse, server.close(), closeIdleConnections(), closeAllConnections()

2 Stable

close() closes idle connections itself since v19.0.0; closeIdleConnections() and closeAllConnections() since v18.2.0. requestTimeout defaults to 300000 ms since v18.0.0; headersTimeout defaults to the smaller of requestTimeout and 60000 ms since v19.4.0 and v18.14.0.

URLPattern (global, also exported by node:url)

1 Experimental

Added to node:url in v23.8.0 and a global since v24.0.0. It does not exist on Node.js 22: new URLPattern() throws ReferenceError: URLPattern is not defined there (observed on 22.23.3). On 26 and 24 it printed no ExperimentalWarning in these runs, which is observed behaviour and not a promise.

node:sqlite: DatabaseSync, prepare(), StatementSync

1.2 Release candidate

Added in v22.5.0, no flag since v23.4.0 and v22.13.0, release candidate since v25.7.0. Node.js 22 prints an ExperimentalWarning when it loads (observed). The bundled SQLite was 3.53.4 on 26 and 24 and 3.51.3 on 22 (process.versions.sqlite), all new enough for STRICT tables and RETURNING.

node:test, node:assert/strict

2 Stable (the test runner; its coverage and watch sections are Experimental)

describe, it, before, after need no flag on any supported line. See Test Runner.

Global fetch, AbortSignal, performance, crypto.randomUUID()

2 Stable

Used as the test client and for request ids and timings.

process.on('SIGTERM'), process.exitCode

2 Stable

SIGTERM and SIGINT have default handlers on non-Windows platforms; installing a listener removes the default. Windows has no real signals (see Process, Signals & Exit Codes).

--env-file-if-exists

Stable

Added in v22.9.0; no longer experimental in v24.10.0 and v22.21.0. It silently skips a missing file, unlike --env-file.

Because URLPattern is the only experimental piece of the HTTP layer, the project declares "engines": { "node": ">=24" }. On Node.js 22 the same code runs unchanged once a URLPattern implementation is preloaded; the section on routing shows the observed run.

What we are building

The resource is a book. The API is intentionally small so that the infrastructure around the handlers, not the handlers, is the subject.

Table 2. The Bookshelf API
Method and path Purpose Success and error statuses

GET /books

List books, newest ids last, with limit, cursor and q (title search) query parameters.

200 with a Link header when another page exists; 400 for an invalid query.

POST /books

Create a book from a JSON body.

201 with a Location header; 400 bad JSON, 409 duplicate ISBN, 413 too large, 415 wrong media type, 422 invalid fields.

GET /books/:id

Read one book.

200; 404 when it does not exist.

PUT /books/:id

Replace a book (every field is sent again).

200; 404, 409, 413, 415, 422.

DELETE /books/:id

Delete a book.

204 with no body; 404.

GET /health, GET /ready

Liveness and readiness probes. Readiness answers 503 while the service drains during shutdown.

200, or 503 for readiness.

Every other path answers 404, and a known path with an unsupported method answers 405 with an Allow header. Every error, whatever produced it, is an application/problem+json document with the same shape. The code is split into small modules so that each concern can be read, tested and replaced on its own.

Project layout
bookshelf-api/
  package.json          type module, engines, scripts
  .env                  local settings (not committed); PORT, DB_PATH, CORS_ORIGINS, LOG_LEVEL
  src/
    config.js           reads and validates the environment once
    errors.js           HttpError, the problem details vocabulary
    log.js              JSON-lines logger
    http.js             sendJson, sendProblem, readJson (body limit)
    validate.js         book and query validation, cursors
    db.js               node:sqlite, migrations, the repository
    router.js           URLPattern routing with 404 / 405
    cors.js             CORS headers and preflight
    app.js              the request pipeline, start() and stop()
    main.js             process entry point: signals and exit codes
  test/
    api.test.js         end-to-end tests with node:test and fetch

The request pipeline

Everything a request goes through is an ordinary function call inside one request listener. There is no framework middleware stack; the order of the steps is the order of the statements in handle(), which makes it easy to reason about what runs before what. The diagram shows the path, with the error exit that every stage shares.

flowchart TD A[Incoming request] --> B["Stage 1: request id and baseline headers"] B --> C["Stage 2: access log registered on close"] C --> D{"Stage 3: origin-form target?"} D -- no --> X D -- yes --> E{"Stage 4: route found?"} E -- no path --> X E -- yes --> F{"Stage 5: OPTIONS?"} F -- yes --> G["204 with Allow and CORS headers"] F -- no --> H{"method allowed?"} H -- no --> X H -- yes --> I["Stage 6: handler"] I --> J["readJson: type and size limit"] J --> K["parseBook or parseListQuery"] K --> L["repository: node:sqlite"] L --> M["sendJson: 200 / 201 / 204"] J -. error .-> X K -. error .-> X L -. error .-> X X["Stage 7: sendProblem as application/problem+json"] --> Z[Response and access log line] M --> Z G --> Z
Figure 1. The request pipeline of the Bookshelf API

Two properties of this shape are worth naming. First, errors are values of one type: any stage can throw new HttpError(…​), and the single catch at stage 7 turns it into a response, so handlers contain only the happy path. Second, cross-cutting data is set before anything can fail: the request id and the CORS headers are already on the response when an error is produced, so even a 404 or a 500 is traceable and readable by a browser client.

Configuration: read once, fail fast

Configuration comes from environment variables, as the Twelve-Factor tradition and every container platform expect, and it is validated once at start-up. A typo in PORT should stop the process with a clear message, not surface later as a confusing runtime error. The loader takes an env object, which defaults to process.env, so tests can pass their own values without touching the real environment.

src/config.js
// Read and validate configuration once, at start-up, and fail fast on bad values.
function integer(env, name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
  const raw = env[name];
  if (raw === undefined || raw === '') return fallback;
  const value = Number(raw);
  if (!Number.isInteger(value) || value < min || value > max) {
    throw new Error(`${name} must be an integer between ${min} and ${max}, got "${raw}"`);
  }
  return value;
}

export function loadConfig(env = process.env) {
  return {
    host: env.HOST ?? '127.0.0.1',
    port: integer(env, 'PORT', 3000, { max: 65535 }),   // 0 asks the OS for a free port
    dbPath: env.DB_PATH ?? ':memory:',
    maxBodyBytes: integer(env, 'MAX_BODY_BYTES', 16 * 1024, { min: 1 }),
    corsOrigins: new Set((env.CORS_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean)),
    shutdownTimeoutMs: integer(env, 'SHUTDOWN_TIMEOUT_MS', 10_000, { min: 1 }),
    logLevel: env.LOG_LEVEL ?? 'info',
  };
}

Two details deserve a comment. Port 0 is valid and means "any free port", which is what the tests use, so a missing or mistyped value must not silently become 0; the loader accepts only integers within the stated range. And the file the process starts with is read by Node.js itself: node --env-file-if-exists=.env src/main.js loads .env into process.env before the first line of your code runs, so no dotenv package is needed. A real environment variable that is already set wins over the same name in the file, which lets a container override local defaults. Keep .env out of version control, commit an .env.example with placeholder values, and never put secrets in URLs or in log lines. This service has no secrets of its own; when it gains a database password or an API key, they arrive the same way. See CLI Options, Environment & Config File for --env-file in detail.

Errors as problem details

HTTP APIs need one error format that clients can parse without guessing. RFC 9457 (Problem Details for HTTP APIs, which obsoletes RFC 7807) defines the media type application/problem+json and five standard members: type (a URI that identifies the kind of problem), title (a short summary that does not change between occurrences), status (the HTTP status, repeated in the body), detail (an explanation of this occurrence) and instance (a URI reference that identifies this occurrence). Additional members are allowed, and this API adds errors, a list of field problems with a JSON Pointer-style pointer, and requestId, the correlation id also sent in the X-Request-Id header. A consumer that only understands the standard members still gets a useful message.

src/errors.js
// An error that knows how to describe itself as an RFC 9457 problem details object.
export class HttpError extends Error {
  constructor(status, title, { detail, type = 'about:blank', errors, headers, cause } = {}) {
    super(detail ?? title, { cause });
    this.name = 'HttpError';
    this.status = status;
    this.title = title;
    this.detail = detail;
    this.type = type;
    this.errors = errors;
    this.headers = headers;
  }
}

export const notFound = (detail) => new HttpError(404, 'Not Found', { detail });
export const badRequest = (detail, cause) => new HttpError(400, 'Bad Request', { detail, cause });

export const PROBLEM_BASE = 'https://bookshelf.example/problems/';

HttpError is the only error type that handlers throw on purpose. It carries the status, the title, optional detail, type, errors and extra headers (for example Allow on a 405), and passes the original exception along as cause so that logs can show it while the client never sees it. The type defaults to about:blank, which RFC 9457 defines as "no additional semantics beyond the status code"; the specific problems use URLs under https://bookshelf.example/problems/, a name in the reserved .example top-level domain, so the identifiers are stable even though nothing resolves there. In a real service the URL would lead to a page that explains the problem. The two small helpers cover the most common cases.

The functions that write the responses live next to the body reader.

src/http.js
import { HttpError } from './errors.js';

export function sendJson(res, status, body, headers = {}) {
  const payload = JSON.stringify(body);
  res.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': Buffer.byteLength(payload),
    ...headers,
  });
  res.end(payload);
}

export function sendProblem(res, err, { instance, requestId }) {
  const body = {
    type: err.type, title: err.title, status: err.status,
    ...(err.detail && { detail: err.detail }),
    instance,
    ...(err.errors && { errors: err.errors }),
    requestId,
  };
  const payload = JSON.stringify(body);
  res.writeHead(err.status, {
    'Content-Type': 'application/problem+json; charset=utf-8',
    'Content-Length': Buffer.byteLength(payload),
    ...err.headers,
  });
  res.end(payload);
}

function mediaType(header = '') {
  return header.split(';')[0].trim().toLowerCase();
}

// Read a JSON request body with a hard size limit; never buffers more than `limit` bytes.
export async function readJson(req, { limit }) {
  if (mediaType(req.headers['content-type']) !== 'application/json') {
    throw new HttpError(415, 'Unsupported Media Type', {
      detail: 'Send the body as application/json.',
    });
  }
  const tooLarge = () => new HttpError(413, 'Payload Too Large', {
    detail: `The request body must not exceed ${limit} bytes.`,
    headers: { Connection: 'close' },        // do not try to reuse a connection with unread bytes
  });
  const declared = req.headers['content-length'];
  if (declared !== undefined && Number(declared) > limit) throw tooLarge();

  const chunks = [];
  let size = 0;
  for await (const chunk of req) {           // IncomingMessage is a Readable stream
    size += chunk.length;
    if (size > limit) throw tooLarge();      // a chunked body has no declared length to trust
    chunks.push(chunk);
  }
  if (size === 0) throw new HttpError(400, 'Bad Request', { detail: 'The request body is empty.' });
  try {
    return JSON.parse(Buffer.concat(chunks).toString('utf8'));
  } catch (cause) {
    throw new HttpError(400, 'Bad Request', { detail: 'The request body is not valid JSON.', cause });
  }
}

sendJson() and sendProblem() set Content-Length themselves, using Buffer.byteLength() because the header counts bytes and not characters: a title with an accented letter has more bytes than characters, and a wrong length corrupts keep-alive connections. Setting the length also avoids chunked encoding for small bodies. writeHead() is called with the status and all headers in one go, after which the headers cannot change; this is why the pipeline sets the baseline headers first with setHeader() (which writeHead() merges) and why a handler that fails halfway after sending data can no longer produce a clean error response (the res.headersSent check in the pipeline handles that case by aborting the connection).

Table 3. Choosing the status for each failure
Situation Status Reasoning

Body is not valid JSON, or the query string is malformed

400

The request could not be understood at all; nothing was validated.

Path matches no route, or the resource does not exist

404

/nope and /books/99 are both "not found"; detail says which.

Known path, unsupported method

405

Must carry an Allow header listing the supported methods (RFC 9110).

The ISBN already exists

409

The request is valid but conflicts with the current state of the resource.

Body larger than the limit

413

Sent before the body is read in full; the connection is closed afterwards.

Body is not application/json

415

The server will not interpret that content type.

Well-formed JSON with invalid fields

422

Syntax is fine, semantics are not. RFC 9110 names it "Unprocessable Content"; http.STATUS_CODES[422] still holds the older phrase "Unprocessable Entity" (observed on 26). The page’s title follows the RFC.

An unexpected exception

500

The detail is replaced by a generic message; the real error goes to the log with the same requestId.

The service is draining

503

Only on the readiness probe; ordinary requests that arrive while draining are still answered.

Parsing JSON bodies with limits

An IncomingMessage is a Readable stream (see Streams), so a request body arrives in chunks and is not available until you read it. readJson() in http.js above is the whole body parser, and each statement in it answers a specific failure mode.

  • Media type first. It compares only the media type, lower-cased and without parameters, so application/json; charset=utf-8 passes and text/plain is answered with 415 before any byte of the body is read.

  • Declared size first. A Content-Length above the limit is refused immediately, which is cheap and stops an honest client from uploading a large body in vain.

  • Counted size always. The declared length can be absent (chunked transfer) or a lie, so the loop adds up the real chunk lengths and stops as soon as the sum passes the limit. Memory use is bounded by the limit plus one chunk, whatever the client sends.

  • Connection: close on a 413. The unread remainder of the body would otherwise sit in front of the next request on a keep-alive connection. Closing the connection discards it.

  • Empty and malformed bodies. An empty body and a body that is not JSON are both 400, with the parse error kept as the cause, not echoed to the client.

  • Decode after concatenating. Chunk boundaries can split a multi-byte character, so the chunks are joined as Buffer first and decoded once.

The default limit of 16 KiB (MAX_BODY_BYTES) is generous for a book record and small enough to be harmless. Choose a limit per route in a real service: a metadata endpoint should accept kilobytes, a file upload endpoint gets its own streaming path (see HTTP Server Fundamentals). The json() consumer from node:stream/consumers is a one-line alternative, but it has no limit at all, so it is only safe behind another layer that bounds the body. The limit protects memory; time limits are the server’s requestTimeout and headersTimeout, configured on the server below and explained in Server Timeouts, Limits & Hardening.

A note on JSON.parse() and prototype pollution: parsing {"proto": {"x": 1}} creates an ordinary own property named proto and does not change Object.prototype (observed: after parsing, ({}).x is undefined). The danger arises later, when code copies or merges parsed objects into other objects recursively. The validator below avoids it by building a new object from a fixed list of known fields and rejecting unknown keys, so no attacker-chosen key ever reaches storage or a merge.

Validation

Validation is where a REST API earns its reputation. Node.js has no built-in schema validator, so this page writes the checks by hand to show what they must do; frameworks such as Fastify attach JSON Schema validation (Ajv) to routes, shown in Web Frameworks: Express & Fastify. The rules in validate.js are the ones every hand-written validator should have:

  • Collect all the errors, not just the first, so a client can fix a form in one round trip. Each entry has a pointer to the offending field and a detail.

  • Reject unknown fields. A typo such as titel is reported rather than silently ignored, and no field the server did not plan for is ever stored.

  • Check types before ranges. typeof first, then length or numeric range; Number.isInteger() rejects 2008.5 and "2008".

  • Normalise after validating. The ISBN is stored without hyphens and the text fields are trimmed, so equal books are equal in the database.

  • Verify what can be verified. An ISBN-13 has a check digit (digits weighted 1, 3, 1, 3, …​ must sum to a multiple of 10), which catches transposed digits.

  • Return a clean copy. The function returns a new object with only the known fields, never the parsed input.

  • Validate the query string like the body. limit is bounded to 1-100 and the cursor must be one this API issued.

src/validate.js
import { HttpError, PROBLEM_BASE } from './errors.js';

// ISBN-13 check digit: digits weighted 1,3,1,3,... must sum to a multiple of 10.
export function isValidIsbn13(isbn) {
  if (!/^\d{13}$/.test(isbn)) return false;
  const sum = [...isbn].reduce((acc, d, i) => acc + Number(d) * (i % 2 === 0 ? 1 : 3), 0);
  return sum % 10 === 0;
}

const FIELDS = ['isbn', 'title', 'author', 'year'];

// Validate a book payload. Returns a clean object that contains only known fields.
export function parseBook(input) {
  const errors = [];
  const fail = (field, detail) => errors.push({ pointer: `/${field}`, detail });

  if (input === null || typeof input !== 'object' || Array.isArray(input)) {
    throw new HttpError(422, 'Unprocessable Content', {
      type: PROBLEM_BASE + 'validation-error',
      detail: 'The body must be a JSON object.',
    });
  }
  for (const key of Object.keys(input)) {
    if (!FIELDS.includes(key)) fail(key, 'Unknown field.');
  }

  const isbn = typeof input.isbn === 'string' ? input.isbn.replaceAll('-', '') : input.isbn;
  if (typeof isbn !== 'string') fail('isbn', 'Required, as a string.');
  else if (!isValidIsbn13(isbn)) fail('isbn', 'Must be a valid ISBN-13.');

  for (const field of ['title', 'author']) {
    const value = input[field];
    if (typeof value !== 'string') fail(field, 'Required, as a string.');
    else if (value.trim().length === 0 || value.length > 200) fail(field, 'Must be 1 to 200 characters.');
  }

  const { year } = input;
  if (year !== undefined && year !== null) {
    if (!Number.isInteger(year) || year < 1000 || year > new Date().getFullYear() + 1) {
      fail('year', 'Must be an integer between 1000 and next year.');
    }
  }

  if (errors.length > 0) {
    throw new HttpError(422, 'Unprocessable Content', {
      type: PROBLEM_BASE + 'validation-error',
      detail: `${errors.length} field(s) are invalid.`,
      errors,
    });
  }
  return { isbn, title: input.title.trim(), author: input.author.trim(), year: year ?? null };
}

// Query-string validation for the collection: limit (1-100) and an opaque cursor.
export function parseListQuery(searchParams) {
  const errors = [];
  let limit = 20;
  if (searchParams.has('limit')) {
    const raw = searchParams.get('limit');
    limit = /^\d{1,3}$/.test(raw) ? Number(raw) : NaN;
    if (!(limit >= 1 && limit <= 100)) errors.push({ pointer: '?limit', detail: 'Must be an integer from 1 to 100.' });
  }
  let after = 0;
  if (searchParams.has('cursor')) {
    const decoded = Buffer.from(searchParams.get('cursor'), 'base64url').toString('utf8');
    if (/^id:\d{1,15}$/.test(decoded)) after = Number(decoded.slice(3));
    else errors.push({ pointer: '?cursor', detail: 'Not a cursor issued by this API.' });
  }
  const q = searchParams.get('q') ?? '';
  if (q.length > 100) errors.push({ pointer: '?q', detail: 'Must be at most 100 characters.' });
  if (errors.length > 0) {
    throw new HttpError(400, 'Bad Request', {
      type: PROBLEM_BASE + 'invalid-query', detail: 'Invalid query parameters.', errors,
    });
  }
  return { limit, after, q };
}

export const encodeCursor = (id) => Buffer.from(`id:${id}`).toString('base64url');

Validation of the shape (types, lengths, ranges) is the transport layer’s job. Business rules that need the database, such as "the ISBN must be unique", are enforced where the data lives: the UNIQUE constraint in the schema is the only race-free check, so the code lets the insert fail and maps the failure to 409. A "check, then insert" sequence has a time-of-check to time-of-use gap that two concurrent requests can both pass.

An invalid request, with every problem listed at once, looks like this on the wire (the request id differs on every run):

A 422 response
{
  "type": "https://bookshelf.example/problems/validation-error",
  "title": "Unprocessable Content",
  "status": 422,
  "detail": "4 field(s) are invalid.",
  "instance": "/books",
  "errors": [
    { "pointer": "/extra", "detail": "Unknown field." },
    { "pointer": "/isbn", "detail": "Must be a valid ISBN-13." },
    { "pointer": "/title", "detail": "Must be 1 to 200 characters." },
    { "pointer": "/author", "detail": "Required, as a string." }
  ],
  "requestId": "23bb963b-a63b-4258-b8f5-a6d189e7ddff"
}

Persistence with node:sqlite

node:sqlite gives the service a real SQL database with no dependency, no server process and no native build step. It is the right size for a prototype, a CLI, a test fixture or a small single-node service, and it is Stability 1.2 (Release candidate), so keep the repository boundary thin enough to swap it for PostgreSQL later; see SQLite Built-in for the full API and Databases from Node.js for server databases. The module is synchronous: prepare(), get(), all() and run() block the event loop for the duration of the query. For an embedded database answering from local disk that is usually microseconds, and it avoids thread-pool hops, but a slow query stalls every other request, which is why every query here is bounded by an index and a LIMIT (see Don’t Block the Event Loop).

src/db.js
import { DatabaseSync } from 'node:sqlite';

// Each entry is applied once, in order; PRAGMA user_version stores how many have run.
const MIGRATIONS = [
  `CREATE TABLE books (
     id         INTEGER PRIMARY KEY,
     isbn       TEXT NOT NULL UNIQUE,
     title      TEXT NOT NULL,
     author     TEXT NOT NULL,
     year       INTEGER,
     created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
   ) STRICT`,
  `CREATE INDEX books_title ON books (title COLLATE NOCASE)`,
];

export function openDatabase(path) {
  const db = new DatabaseSync(path, { timeout: 5000 });  // wait up to 5 s on a locked file
  db.exec('PRAGMA foreign_keys = ON');
  if (path !== ':memory:') db.exec('PRAGMA journal_mode = WAL');
  const { user_version: current } = db.prepare('PRAGMA user_version').get();
  for (let v = current; v < MIGRATIONS.length; v++) {
    db.exec('BEGIN');
    try {
      db.exec(MIGRATIONS[v]);
      db.exec(`PRAGMA user_version = ${v + 1}`);          // v is our own counter, never user input
      db.exec('COMMIT');
    } catch (err) {
      db.exec('ROLLBACK');
      throw err;
    }
  }
  return db;
}

// SQLITE_CONSTRAINT_UNIQUE is extended result code 2067 (see sqlite.org/rescode.html).
export const isUniqueViolation = (err) => err?.code === 'ERR_SQLITE_ERROR' && err.errcode === 2067;

const escapeLike = (s) => s.replace(/[\\%_]/g, '\\$&');

export function createBookRepository(db) {
  const columns = 'id, isbn, title, author, year, created_at AS createdAt';
  const insert = db.prepare(
    `INSERT INTO books (isbn, title, author, year) VALUES (?, ?, ?, ?) RETURNING ${columns}`);
  const byId = db.prepare(`SELECT ${columns} FROM books WHERE id = ?`);
  const replace = db.prepare(
    `UPDATE books SET isbn = ?, title = ?, author = ?, year = ? WHERE id = ? RETURNING ${columns}`);
  const remove = db.prepare('DELETE FROM books WHERE id = ?');
  // Keyset pagination: "rows after the last id I saw", served by the primary key.
  const page = db.prepare(
    `SELECT ${columns} FROM books
      WHERE id > ? AND title LIKE ? ESCAPE '\\'
      ORDER BY id LIMIT ?`);

  return {
    create: (b) => insert.get(b.isbn, b.title, b.author, b.year),
    find: (id) => byId.get(id),
    replace: (id, b) => replace.get(b.isbn, b.title, b.author, b.year, id),
    delete: (id) => remove.run(id).changes > 0,
    // Ask for limit + 1 rows: the extra row tells us whether a next page exists.
    list({ after, q, limit }) {
      const rows = page.all(after, `%${escapeLike(q)}%`, limit + 1);
      const hasMore = rows.length > limit;
      const items = hasMore ? rows.slice(0, limit) : rows;
      return { items, nextAfter: hasMore ? items.at(-1).id : null };
    },
    ping: () => db.prepare('SELECT 1 AS ok').get().ok === 1,
  };
}

The design choices in that file are the transferable part.

  • Schema as ordered migrations. The list of statements is the schema history, and PRAGMA user_version (a free integer in the database header) records how many have run, so starting the service twice applies each migration exactly once. Each migration runs in a transaction together with the version bump, so a failure leaves nothing half-applied. The PRAGMA text is built from a loop counter that the code owns, never from input, because a PRAGMA cannot take bound parameters.

  • STRICT tables make SQLite enforce column types (without it, SQLite stores any value in any column), and the UNIQUE constraint on isbn is the duplicate guard.

  • journal_mode = WAL lets readers continue while a writer writes. It applies to file databases only, so it is skipped for :memory:, and WAL needs a filesystem with shared memory, so it is unsuitable on network filesystems.

  • timeout: 5000 makes a statement wait up to five seconds for a lock held by another connection instead of failing at once with "database is locked".

  • Prepared once, executed many times. The StatementSync objects are created when the repository is created and reused for every request; parameters are bound with ? placeholders, so user input is never concatenated into SQL. This is the injection defence, and it is also faster.

  • RETURNING gives the inserted or updated row in the same statement, so no second query (and no race) is needed to read it back. Note the alias created_at AS createdAt: the column names of the API are decided in the SQL.

  • LIKE wildcards are data. A search for % should find a literal percent sign, so the search text is escaped and the statement says ESCAPE '\'.

  • Mapping the constraint error. A unique violation surfaces as an error whose code is ERR_SQLITE_ERROR (documented in the errors page) and which, in the runs for this page, also carried errcode 2067 (SQLite’s SQLITE_CONSTRAINT_UNIQUE) and errstr. The two extra properties are observed behaviour and not described in the Node.js documentation, so a production service should pin the Node.js version in its tests that exercise this path.

  • Closing the database on shutdown (db.close(), below) flushes and releases the file.

Pagination

Returning every row is not an option for a collection, and there are two common answers. The repository uses the second.

Table 4. Offset pagination versus keyset (cursor) pagination
Aspect Offset (?page=40&size=20) Keyset (?cursor=…​&limit=20)

Query

ORDER BY id LIMIT 20 OFFSET 800

WHERE id > :last ORDER BY id LIMIT 20

Cost for a deep page

The database walks and discards every skipped row. In a check with EXPLAIN QUERY PLAN the offset form reported SCAN b.

It seeks straight to the position. The keyset form reported SEARCH b USING INTEGER PRIMARY KEY (rowid>?).

Stability while data changes

Inserts and deletes shift the window, so a client can see a row twice or miss one.

The cursor names a position, so concurrent writes do not duplicate or skip rows.

Random access to page N

Yes.

No: only "next" (and "previous" with a second cursor).

Needs

Nothing.

A unique, indexed, immutable sort key (here the primary key; with another sort key add the id as a tie-breaker).

See Pagination Strategies for the general comparison. Three implementation details are worth copying.

  • Fetch limit + 1 rows. The extra row answers "is there a next page?" without a separate COUNT(*), which would scan the table. The extra row is not returned.

  • The cursor is opaque but not secret. Here it is base64url("id:42"). Clients must treat it as a token, which leaves room to change the format later, and the server must treat it as untrusted input: parseListQuery() decodes it and accepts only the exact expected shape. An encoded cursor is not a signature, so never put anything in it that the client must not see or alter.

  • Announce the next page twice. The body carries page.next for clients that read JSON and the Link header with rel="next" for generic HTTP tooling. Both are relative references that already contain limit and any q, so following them reproduces the same query.

Routing with URLPattern

URLPattern is the WHATWG pattern matcher: you describe the shape of a URL and ask a URL or a plain object whether it matches. It replaces the long chains of if (url === …​) that hand-written servers collect, and it returns named groups, so no regular expression bookkeeping is needed. The matching is done by pattern.exec({ pathname }), which returns null or an object whose pathname.groups holds the captured parameters.

src/router.js
// A tiny router on top of the URLPattern global (Node.js 24+).
export function createRouter() {
  const routes = [];

  function route(path) {
    let entry = routes.find((r) => r.path === path);
    if (!entry) {
      entry = { path, pattern: new URLPattern({ pathname: path }), handlers: new Map() };
      routes.push(entry);
    }
    return entry;
  }

  return {
    add(method, path, handler) {
      route(path).handlers.set(method, handler);
      return this;
    },
    match(method, pathname) {
      for (const { pattern, handlers } of routes) {
        const result = pattern.exec({ pathname });
        if (!result) continue;
        const handler = handlers.get(method) ?? (method === 'HEAD' ? handlers.get('GET') : undefined);
        const allow = [...new Set([...handlers.keys(), ...(handlers.has('GET') ? ['HEAD'] : []), 'OPTIONS'])];
        return handler
          ? { handler, params: result.pathname.groups, allow }
          : { handler: null, params: {}, allow };          // path exists, method does not
      }
      return null;                                         // no such path
    },
  };
}

The router is thirty lines because it only needs to answer three questions, and the answer shapes are the interesting part.

  • Found: the handler and the captured parameters.

  • Path known, method not: handler: null plus the allow list, which becomes a 405 with an Allow header. Without this distinction a wrong method looks like a missing page, which is misleading for client developers.

  • No such path: null, which becomes 404.

HEAD is served by the GET handler (Node.js omits the body for a HEAD response by itself), and OPTIONS is always allowed because the CORS preflight uses it. The id pattern :id(\\d{1,15}) carries its own regular expression, so /books/abc and /books/%31 simply do not match and become 404 without any code in the handler, and the id always fits in a JavaScript safe integer. The matching is strict: /books/ with a trailing slash is a different path from /books and answers 404 (observed). Whether to redirect or to normalise is a policy choice; the important thing is to choose it deliberately.

Node.js 22 and the request target

Node.js 22 has no URLPattern. In the run for this page the test suite on 22.23.3 failed at start-up with ReferenceError: URLPattern is not defined. Preloading the urlpattern-polyfill package (a third-party implementation of the same standard) makes the unchanged code pass all ten tests on 22.23.3: install it into the project and start Node.js with a one-line preload module.

Running on Node.js 22 with a polyfill (observed: 10 of 10 tests passed)
npm install urlpattern-polyfill
echo "import 'urlpattern-polyfill';" > preload.js
node --import ./preload.js --test
node --import ./preload.js --env-file-if-exists=.env src/main.js

Node.js 22 is in Maintenance and its end of life is 2027-04-30, so the polyfill is a bridge for existing deployments; new code should target 24 or 26.

One more piece of routing hygiene happens before the router: the request target. req.url is whatever the client wrote after the method, and the HTTP parser accepts several forms. The pipeline accepts only the origin-form (a path starting with a single /) and answers 400 for anything else, because new URL('//evil.example/books', base) treats a leading // as a network-path reference and moves the host, which would make the parsed pathname differ from what the client meant to ask for. The check takes one line and removes the ambiguity (observed: //evil.example/books answered 400). The base passed to new URL() is a dummy host in the reserved .invalid top-level domain: only the path and query are used, and the real Host header is never trusted for routing decisions.

CORS

Cross-Origin Resource Sharing is a browser policy. A page loaded from https://app.example that calls https://api.example with fetch() is a cross-origin request, and the browser refuses to give the response to the page unless the API says that this origin is allowed. Two facts follow and drive the implementation:

  • CORS is enforced by the browser and protects the user’s browser session, so it is not authentication. curl, server-side code and a malicious page’s server ignore it entirely, and a request from a disallowed origin is still processed by the API (the end-to-end test below asserts that it answers 200 without CORS headers).

  • A request that is not a "simple" request (for example POST with Content-Type: application/json, or PUT, or DELETE) is preceded by a preflight: the browser sends OPTIONS with Origin and Access-Control-Request-Method, and only if the answer allows it sends the real request.

src/cors.js
// CORS for browser clients. Disallowed origins simply receive no CORS headers.
export function createCors({ origins }) {
  const allowed = (origin) => origin !== undefined && origins.has(origin);

  return {
    // Headers added to every response whose request carries an allowed Origin.
    headersFor(req) {
      const origin = req.headers.origin;
      const headers = { Vary: 'Origin' };                 // responses differ per Origin: tell caches
      if (allowed(origin)) {
        headers['Access-Control-Allow-Origin'] = origin;  // an exact, allow-listed value; never "*" blindly
        headers['Access-Control-Expose-Headers'] = 'Location, Link, X-Request-Id';
      }
      return headers;
    },
    // A preflight is an OPTIONS request that names the method the browser wants to use.
    isPreflight: (req) =>
      req.method === 'OPTIONS' && req.headers.origin !== undefined &&
      req.headers['access-control-request-method'] !== undefined,
    preflightHeaders(req, allow) {
      const headers = this.headersFor(req);
      if (allowed(req.headers.origin)) {
        headers['Access-Control-Allow-Methods'] = allow.join(', ');
        headers['Access-Control-Allow-Headers'] = 'Content-Type';
        headers['Access-Control-Max-Age'] = '600';
      }
      return headers;
    },
  };
}

The module follows the rules that most real-world CORS bugs break.

  • Allow-list, do not reflect. The Origin header is copied into Access-Control-Allow-Origin only when it is in the configured CORS_ORIGINS set. Echoing any origin back is equivalent to * and, combined with credentials, hands every site your users' sessions. The string null (sent by sandboxed documents and some redirects) is never in the set unless you add it, and you should not.

  • Vary: Origin always. The response differs by origin, so shared caches must key on it; it is set even for origins that are not allowed.

  • Preflight answers are specific. Access-Control-Allow-Methods lists what the route supports (taken from the router, so it cannot drift), Access-Control-Allow-Headers lists only Content-Type, and Access-Control-Max-Age: 600 lets the browser cache the answer for ten minutes (browsers cap this value).

  • Expose what clients need. By default browser scripts can read only a few "safelisted" response headers, so Location, Link and X-Request-Id are listed in Access-Control-Expose-Headers; without that, the pagination Link header would be invisible to a browser client.

  • Wildcards and credentials do not mix. Access-Control-Allow-Origin: * is refused by browsers for requests that include cookies or an Authorization header. This API sets no cookies, so it does not enable Access-Control-Allow-Credentials; add it only together with an exact origin.

A preflight against the running service answers 204 with an empty body (output with the origin from .env, CORS_ORIGINS=https://app.example, and noise headers removed):

A CORS preflight
HTTP/1.1 204 No Content
Vary: Origin
Access-Control-Allow-Origin: https://app.example
Access-Control-Expose-Headers: Location, Link, X-Request-Id
Allow: GET, POST, HEAD, OPTIONS
Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 600

See CORS for the browser side and the full header reference.

Logging

A service that cannot be observed cannot be operated. The logger writes one JSON object per line to standard output, which is where container runtimes, systemd and log shippers expect it. The writer is injectable, so tests collect log lines in an array instead of printing them.

src/log.js
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: 100 };

// One JSON object per line on stdout: easy for a container runtime or log shipper to collect.
export function createLogger({ level = 'info', write = (line) => process.stdout.write(line), base = {} } = {}) {
  const threshold = LEVELS[level] ?? LEVELS.info;
  const emit = (name) => (fields, msg) => {
    if (LEVELS[name] < threshold) return;
    if (typeof fields === 'string') [fields, msg] = [{}, fields];
    const { err, ...rest } = fields;
    const entry = { time: new Date().toISOString(), level: name, msg, ...base, ...rest };
    if (err) entry.err = { name: err.name, message: err.message, stack: err.stack };
    write(JSON.stringify(entry) + '\n');   // JSON.stringify escapes newlines: no log injection
  };
  return {
    debug: emit('debug'), info: emit('info'), warn: emit('warn'), error: emit('error'),
    child: (extra) => createLogger({ level, write, base: { ...base, ...extra } }),
  };
}
  • Structured, not formatted. Fields are keys, not text to be parsed with a regular expression later. JSON.stringify() escapes newlines and quotes, so a hostile string in a field cannot forge a second log line (log injection).

  • child() binds context. The pipeline creates log.child({ requestId }) per request, so every line written while handling that request, including the error line, carries the id the client sees in X-Request-Id and in the problem body. That is the correlation you search for when a user reports an error.

  • Levels filter at the call site. A message below the threshold is dropped before any serialisation work happens.

  • Errors are expanded explicitly. Error properties are non-enumerable, so a plain JSON.stringify(err) prints {}; the logger copies name, message and stack. Stack traces go to the log and never to the client.

  • What is deliberately not logged: request bodies, Authorization and cookie headers, and query strings. A title is harmless, a password or token in a body or a URL is not, and the end-to-end test asserts that a submitted title does not appear in the access log.

The pipeline writes one access-log line per request from a 'close' handler on the response, so it is emitted exactly once, also when the client disconnects. The line has the method, the path, the status, the duration from performance.now() and a completed flag taken from res.writableFinished. The flag matters: when a client aborts, res.statusCode still holds its default, 200, and only completed: false reveals that nothing was delivered (observed in the forced-shutdown run below).

Access log lines (one per request; ids shortened)
{"time":"2026-10-03T23:33:13.000Z","level":"info","msg":"request","requestId":"b23891a4-…","method":"POST","path":"/books","status":201,"ms":2,"completed":true}
{"time":"2026-10-03T23:33:13.061Z","level":"info","msg":"request","requestId":"23bb963b-…","method":"POST","path":"/books","status":422,"ms":0,"completed":true}
{"time":"2026-10-03T23:33:13.072Z","level":"info","msg":"request","requestId":"01b09a1c-…","method":"POST","path":"/books","status":413,"ms":0,"completed":true}

Two extensions are natural next steps and are covered elsewhere: carrying the request id implicitly with AsyncLocalStorage so that deep functions can log without passing a logger (see Async Context & Cancellation), and swapping this 30-line logger for a production-grade one such as Pino together with metrics and tracing (see Diagnostic Reports, Logging & Observability).

Assembling the application

app.js creates the database, the router and the server, registers the routes, and implements the pipeline from the diagram. Read it top to bottom: the routes are declared first, then handle() runs the stages in order, then the server is configured, and finally start() and stop() give the process a lifecycle.

src/app.js
import { createServer } from 'node:http';
import { once } from 'node:events';
import { randomUUID } from 'node:crypto';
import { HttpError, notFound, badRequest, PROBLEM_BASE } from './errors.js';
import { sendJson, sendProblem, readJson } from './http.js';
import { createRouter } from './router.js';
import { createCors } from './cors.js';
import { createLogger } from './log.js';
import { openDatabase, createBookRepository, isUniqueViolation } from './db.js';
import { parseBook, parseListQuery, encodeCursor } from './validate.js';

const REQUEST_ID = /^[A-Za-z0-9._-]{8,64}$/;

export function createApp(config, { log = createLogger({ level: config.logLevel }) } = {}) {
  const db = openDatabase(config.dbPath);
  const books = createBookRepository(db);
  const cors = createCors({ origins: config.corsOrigins });
  const router = createRouter();
  let draining = false;

  const bookId = (params) => Number(params.id);
  const conflict = () => new HttpError(409, 'Conflict', {
    type: PROBLEM_BASE + 'duplicate-isbn', detail: 'A book with this ISBN already exists.',
  });

  // ---- routes ----------------------------------------------------------------------------
  router
    .add('GET', '/health', ({ res }) => sendJson(res, 200, { status: 'ok' }))
    .add('GET', '/ready', ({ res }) => {
      const ready = !draining && books.ping();
      sendJson(res, ready ? 200 : 503, { status: ready ? 'ready' : 'draining' });
    })
    .add('GET', '/books', ({ res, url }) => {
      const query = parseListQuery(url.searchParams);
      const { items, nextAfter } = books.list(query);
      const headers = {};
      let next = null;
      if (nextAfter !== null) {
        const params = new URLSearchParams({ cursor: encodeCursor(nextAfter), limit: String(query.limit) });
        if (query.q) params.set('q', query.q);
        next = `/books?${params}`;
        headers.Link = `<${next}>; rel="next"`;
      }
      sendJson(res, 200, { data: items, page: { limit: query.limit, next } }, headers);
    })
    .add('POST', '/books', async ({ req, res }) => {
      const input = parseBook(await readJson(req, { limit: config.maxBodyBytes }));
      try {
        const book = books.create(input);
        sendJson(res, 201, book, { Location: `/books/${book.id}` });
      } catch (err) {
        throw isUniqueViolation(err) ? conflict() : err;
      }
    })
    .add('GET', '/books/:id(\\d{1,15})', ({ res, params }) => {
      const book = books.find(bookId(params));
      if (!book) throw notFound(`There is no book with id ${params.id}.`);
      sendJson(res, 200, book);
    })
    .add('PUT', '/books/:id(\\d{1,15})', async ({ req, res, params }) => {
      const input = parseBook(await readJson(req, { limit: config.maxBodyBytes }));
      try {
        const book = books.replace(bookId(params), input);
        if (!book) throw notFound(`There is no book with id ${params.id}.`);
        sendJson(res, 200, book);
      } catch (err) {
        throw isUniqueViolation(err) ? conflict() : err;
      }
    })
    .add('DELETE', '/books/:id(\\d{1,15})', ({ res, params }) => {
      if (!books.delete(bookId(params))) throw notFound(`There is no book with id ${params.id}.`);
      res.writeHead(204).end();
    });

  // ---- the request pipeline ---------------------------------------------------------------
  async function handle(req, res) {
    const started = performance.now();
    const requestId = REQUEST_ID.test(req.headers['x-request-id'] ?? '') ? req.headers['x-request-id'] : randomUUID();
    const reqLog = log.child({ requestId });

    // 1. Identity and baseline headers, set before anything can fail.
    res.setHeader('X-Request-Id', requestId);
    res.setHeader('X-Content-Type-Options', 'nosniff');
    res.setHeader('Cache-Control', 'no-store');
    if (draining) res.setHeader('Connection', 'close');
    for (const [name, value] of Object.entries(cors.headersFor(req))) res.setHeader(name, value);

    // 2. One access-log line per request, written when the response is done (or the socket died).
    res.on('close', () => {
      reqLog.info({
        method: req.method, path: req.url?.split('?')[0], status: res.statusCode,
        ms: Math.round(performance.now() - started), completed: res.writableFinished,
      }, 'request');
    });

    let url;
    try {
      // 3. Parse the request target. Only origin-form ("/path?query") is accepted.
      if (!req.url?.startsWith('/') || req.url.startsWith('//')) throw badRequest('Malformed request target.');
      try { url = new URL(req.url, 'http://bookshelf.invalid'); } catch (cause) { throw badRequest('Malformed URL.', cause); }

      // 4. Route.
      const found = router.match(req.method, url.pathname);
      if (!found) throw notFound(`No route for ${url.pathname}.`);

      // 5. CORS preflight and plain OPTIONS are answered here, before the method check.
      if (req.method === 'OPTIONS') {
        const headers = cors.isPreflight(req) ? cors.preflightHeaders(req, found.allow) : {};
        res.writeHead(204, { Allow: found.allow.join(', '), ...headers }).end();
        return;
      }
      if (!found.handler) {
        throw new HttpError(405, 'Method Not Allowed', { headers: { Allow: found.allow.join(', ') } });
      }

      // 6. Handler: parses the body, validates, talks to the database, writes the response.
      await found.handler({ req, res, url, params: found.params, log: reqLog });
    } catch (err) {
      // 7. Any error ends here as a problem details response.
      if (req.destroyed && !(err instanceof HttpError)) {
        reqLog.warn({ code: err.code }, 'client went away');   // the socket is gone: nobody to answer
        return;
      }
      const problem = err instanceof HttpError ? err : new HttpError(500, 'Internal Server Error');
      if (!(err instanceof HttpError)) reqLog.error({ err }, 'unhandled error');
      else if (err.cause) reqLog.debug({ err: err.cause }, 'cause');
      if (res.headersSent) {
        res.destroy();                   // too late for a clean error response: abort the connection
      } else {
        sendProblem(res, problem, { instance: url?.pathname ?? '/', requestId });
      }
    }
  }

  const server = createServer({ requestTimeout: 30_000, headersTimeout: 15_000, keepAliveTimeout: 5_000 }, handle);
  server.on('clientError', (err, socket) => {
    // Raised for requests the parser itself rejects (before our handler runs).
    if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
    else socket.destroy();
    log.warn({ code: err.code }, 'client error');
  });

  async function start() {
    server.listen({ port: config.port, host: config.host });
    await once(server, 'listening');
    return server.address();
  }

  // Graceful shutdown: stop accepting, let in-flight requests finish, then release resources.
  async function stop() {
    if (draining) return;
    draining = true;
    log.info('draining');
    const closed = once(server, 'close');
    server.close();                      // refuse new connections; also closes idle keep-alive ones
    // A connection that was busy when we started draining becomes idle later: sweep until closed.
    const sweep = setInterval(() => server.closeIdleConnections(), 100);
    sweep.unref();
    const timer = setTimeout(() => {
      log.warn('shutdown timeout, closing remaining connections');
      server.closeAllConnections();
    }, config.shutdownTimeoutMs);
    timer.unref();
    await closed;
    clearInterval(sweep);
    clearTimeout(timer);
    db.close();
    log.info('stopped');
  }

  return { server, start, stop };
}

The notes below follow the numbered comments in handle().

Table 5. What each stage does and why it sits where it does
Stage Code Reason for the position

1

Request id (accepted from the client only if it matches a conservative pattern, else crypto.randomUUID()), baseline headers, CORS headers

Before anything can throw, so every response, including errors, is traceable and readable cross-origin. Validating the inbound id stops a client from injecting arbitrary text (or newlines, which Node.js would reject anyway) into your logs and headers. nosniff stops browsers from guessing a content type, and Cache-Control: no-store keeps API data out of shared caches.

2

res.on('close', …​) registers the access log

'close' fires once for finished and for aborted responses, so no request escapes the log, whatever happens later.

3

Request target check and new URL()

Everything after this point can use a trusted url object.

4

router.match()

Decides 404 versus 405 before spending any effort on the body.

5

OPTIONS handling

A preflight must be answered before the method check, because OPTIONS is not a registered method for the route.

6

The handler

Receives only what it needs: req, res, url, params and a request-scoped logger.

7

catch

The only place that turns exceptions into responses. Unknown errors become 500 with a generic message and are logged with the stack; a request whose socket is already gone is logged as client went away and gets no response; if the headers were already sent, the connection is destroyed instead of writing a second status line.

The async request listener deserves one remark. http.createServer() ignores the value the listener returns, so a rejected promise from an async listener would become an unhandled rejection and could crash the process (see Errors & Error Handling). That is why handle() wraps everything after the logging setup in one try/catch, and why an error in the catch itself (for example while writing to a closed socket) cannot escape: sendProblem() only calls synchronous writeHead() and end().

The server options set the time limits explicitly: headersTimeout of 15 seconds for the header block, requestTimeout of 30 seconds for the whole request and keepAliveTimeout of 5 seconds for idle connections. Their defaults on current Node.js are 60 seconds (or requestTimeout, if smaller), 300 seconds and 5 seconds. A request that exceeds headersTimeout or requestTimeout receives 408 and the connection is closed before the listener runs. The 'clientError' listener handles requests that the HTTP parser rejects before any request object exists (malformed request line or headers): by default Node.js answers 400 and closes the socket, and the listener does the same explicitly while logging a warning, because once you add a 'clientError' listener you own the response. Behind a reverse proxy set the server’s keepAliveTimeout above the proxy’s idle timeout, otherwise the proxy may reuse a connection the server has just closed; see Server Timeouts, Limits & Hardening.

Graceful shutdown

A deployment, a scale-down or an operator’s Ctrl+C stops the process with a signal. Orchestrators send SIGTERM, wait for a grace period (30 seconds by default in Kubernetes) and only then send an unblockable SIGKILL. A service that exits immediately on SIGTERM drops requests that are half way through; a service that ignores it is killed after the grace period. The correct sequence is to stop accepting new work, let the work in flight finish, release resources, and exit before the deadline.

sequenceDiagram participant O as Orchestrator participant M as main.js participant A as app.stop participant S as http.Server participant D as SQLite O->>M: SIGTERM M->>A: stop A->>A: draining = true, /ready answers 503 A->>S: close, no new connections loop every 100 ms A->>S: closeIdleConnections end S-->>A: in-flight requests finish alt in-flight work exceeds SHUTDOWN_TIMEOUT_MS A->>S: closeAllConnections end S-->>A: close event A->>D: db.close A-->>M: resolved M-->>O: event loop empty, process exits with code 0
Figure 2. Graceful shutdown of the Bookshelf API

The mechanism is the stop() function at the end of app.js above and the entry point that wires signals to it.

src/main.js
import { loadConfig } from './config.js';
import { createApp } from './app.js';
import { createLogger } from './log.js';

const config = loadConfig();
const log = createLogger({ level: config.logLevel });
const app = createApp(config, { log });

const address = await app.start();
log.info({ port: address.port, host: address.address }, 'listening');

// First signal: graceful shutdown. Second signal: give up and exit immediately.
let stopping = false;
for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, async () => {
    if (stopping) process.exit(1);
    stopping = true;
    log.info({ signal }, 'signal received');
    try {
      await app.stop();
    } catch (err) {
      log.error({ err }, 'error during shutdown');
      process.exitCode = 1;
    }
  });
}

Each step has a reason.

  • draining first. The readiness probe starts answering 503 so a load balancer stops sending traffic, and responses to requests that are already on keep-alive connections carry Connection: close, which tells clients not to reuse the connection.

  • server.close() stops the listener and, since Node.js 19, also closes idle keep-alive connections. It returns immediately; the 'close' event fires only when every connection has ended, which is why stop() awaits the event.

  • The sweep with closeIdleConnections(). In a run for this page, a request that was in flight when draining began finished its response (still answered with Connection: keep-alive, because the header was chosen when the request started) and left its connection idle and open. Without the sweep, stop() took 6055 ms to resolve, which is the 5 second keep-alive timeout plus the default 1 second keepAliveTimeoutBuffer; with the sweep it took 101 ms (both observed on Node.js 26.10.0). The documentation says close() closes idle connections, but a connection that is busy at that moment only becomes idle later, and nothing closes it. Sweeping every 100 ms until 'close' fires makes shutdown as fast as the slowest request. The test asserts that shutdown completes in under two seconds.

  • A deadline. SHUTDOWN_TIMEOUT_MS (default 10 seconds, which should be shorter than the orchestrator’s grace period) arms a timer that calls closeAllConnections(), which destroys sockets even if requests are unfinished. In a run with a stalled upload (headers sent, body never completed) and a 500 ms deadline, the log showed draining, then the warning shutdown timeout, closing remaining connections 500 ms later, then stopped, the stalled request logged with completed: false and client went away, and the process exited with code 0. The timer is unref()-ed so it can never keep the process alive on its own.

  • Close resources last. The database is closed after the server, because handlers use it; closing it first would turn the in-flight requests into errors.

  • No process.exit(). Once the server is closed, the database closed and the timers cleared, the event loop empties and Node.js exits by itself with process.exitCode. An explicit process.exit() could truncate buffered log output on a pipe and hides leaks of open handles that a clean exit would reveal. The only process.exit(1) is the impatient operator’s second signal.

  • Platform note. Windows has no real signals: the documentation says sending SIGTERM to a process there terminates it unconditionally, so a graceful shutdown on Windows needs another trigger, such as a message or a console event; see Process, Signals & Exit Codes.

Production Deployment extends this to health probes behind a reverse proxy and to container orchestration, and Cluster & Scaling shows how a primary process coordinates the shutdown of several workers.

Running the service

The scripts in package.json use only Node.js itself. The .env file holds non-secret local settings; PORT=0 lets the operating system choose a free port, which the listening log line then reports.

package.json and .env
{
  "name": "bookshelf-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24" },
  "scripts": {
    "start": "node --env-file-if-exists=.env src/main.js",
    "test": "node --test"
  }
}
cat > .env <<'ENV'
PORT=0
DB_PATH=bookshelf.db
CORS_ORIGINS=https://app.example
LOG_LEVEL=info
ENV
npm start

With the port from the first log line (here 52380) in BASE, a session with curl exercises the whole API.

Create, list and page through books
BASE=http://127.0.0.1:52380
curl -si -X POST $BASE/books -H 'Content-Type: application/json' \
  -d '{"isbn":"978-0-13-235088-4","title":"Clean Code","author":"Robert C. Martin","year":2008}'
curl -si "$BASE/books?limit=2"
curl -s "$BASE/books?limit=2&cursor=aWQ6Mg"
curl -si -X PATCH $BASE/books/1
Output (abridged; headers other than the interesting ones removed)
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /books/1

{"id":1,"isbn":"9780132350884","title":"Clean Code","author":"Robert C. Martin","year":2008,"createdAt":"2026-10-03T23:30:41.898Z"}

HTTP/1.1 200 OK
Link: </books?cursor=aWQ6Mg&limit=2>; rel="next"

{"data":[{"id":1,…},{"id":2,…}],"page":{"limit":2,"next":"/books?cursor=aWQ6Mg&limit=2"}}

{"data":[{"id":3,…},{"id":4,…}],"page":{"limit":2,"next":"/books?cursor=aWQ6NA&limit=2"}}

HTTP/1.1 405 Method Not Allowed
Content-Type: application/problem+json; charset=utf-8
Allow: GET, PUT, DELETE, HEAD, OPTIONS

Stopping the process with Ctrl+C or kill -TERM prints the shutdown lines, and the data survives a restart because DB_PATH points at a file (in the run for this page the five rows were still there and the journal mode reported wal).

Shutdown log (ids and times shortened)
{"level":"info","msg":"signal received","signal":"SIGTERM"}
{"level":"info","msg":"draining"}
{"level":"info","msg":"stopped"}

End-to-end tests with node:test

The tests start the real application on port 0, call it with the platform fetch() and assert on status codes, headers and bodies. Nothing is mocked: the router, the validator, the logger and a real in-memory SQLite database are all exercised, so these tests catch wiring mistakes that unit tests cannot. They run in milliseconds, which makes them practical for every commit.

test/api.test.js
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import { loadConfig } from '../src/config.js';
import { createApp } from '../src/app.js';
import { createLogger } from '../src/log.js';

const ISBNS = ['9780132350884', '9780201633610', '9780134685991', '9781491950296', '9781098145187'];

describe('Bookshelf REST API', () => {
  let app;
  let base;
  const logLines = [];

  const send = (path, { method = 'GET', body, headers = {} } = {}) =>
    fetch(base + path, {
      method,
      headers: { ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers },
      body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
    });

  before(async () => {
    const config = loadConfig({ PORT: '0', DB_PATH: ':memory:', MAX_BODY_BYTES: '512', CORS_ORIGINS: 'https://app.example' });
    const log = createLogger({ level: 'info', write: (line) => logLines.push(JSON.parse(line)) });
    app = createApp(config, { log });
    const { port } = await app.start();            // port 0: the OS picks a free port
    base = `http://127.0.0.1:${port}`;
  });

  after(() => app.stop());

  it('creates, reads, replaces and deletes a book', async () => {
    const created = await send('/books', { method: 'POST', body: { isbn: '978-0-13-235088-4', title: 'Clean Code', author: 'Robert C. Martin', year: 2008 } });
    assert.equal(created.status, 201);
    const book = await created.json();
    assert.equal(created.headers.get('location'), `/books/${book.id}`);
    assert.equal(book.isbn, '9780132350884');           // hyphens are normalised away

    const read = await send(`/books/${book.id}`);
    assert.deepEqual(await read.json(), book);

    const put = await send(`/books/${book.id}`, { method: 'PUT', body: { isbn: book.isbn, title: 'Clean Code, 2nd ed.', author: book.author } });
    assert.equal((await put.json()).title, 'Clean Code, 2nd ed.');

    assert.equal((await send(`/books/${book.id}`, { method: 'DELETE' })).status, 204);
    assert.equal((await send(`/books/${book.id}`)).status, 404);
  });

  it('answers errors as problem details', async () => {
    const res = await send('/books', { method: 'POST', body: { isbn: '123', title: '', extra: true } });
    assert.equal(res.status, 422);
    assert.match(res.headers.get('content-type'), /^application\/problem\+json/);
    const problem = await res.json();
    assert.equal(problem.status, 422);
    assert.deepEqual(problem.errors.map((e) => e.pointer).sort(), ['/author', '/extra', '/isbn', '/title']);
    assert.equal(problem.requestId, res.headers.get('x-request-id'));
  });

  it('rejects bad bodies before they reach the database', async () => {
    assert.equal((await send('/books', { method: 'POST', body: '{ nope' })).status, 400);
    assert.equal((await send('/books', { method: 'POST', body: 'plain', headers: { 'content-type': 'text/plain' } })).status, 415);
    const big = await send('/books', { method: 'POST', body: { title: 'x'.repeat(2000) } });
    assert.equal(big.status, 413);
    assert.equal(big.headers.get('connection'), 'close');
  });

  it('answers 404, 405 and OPTIONS with the right headers', async () => {
    assert.equal((await send('/nope')).status, 404);
    const res = await send('/books/1', { method: 'PATCH', body: {} });
    assert.equal(res.status, 405);
    assert.equal(res.headers.get('allow'), 'GET, PUT, DELETE, HEAD, OPTIONS');
  });

  it('returns 409 for a duplicate ISBN', async () => {
    const body = { isbn: ISBNS[1], title: 'Refactoring', author: 'Martin Fowler' };
    assert.equal((await send('/books', { method: 'POST', body })).status, 201);
    const res = await send('/books', { method: 'POST', body });
    assert.equal(res.status, 409);
    assert.match((await res.json()).type, /duplicate-isbn$/);
  });

  it('paginates with an opaque cursor and a Link header', async () => {
    for (const [i, isbn] of ISBNS.slice(2).entries()) {
      await send('/books', { method: 'POST', body: { isbn, title: `Paged ${i}`, author: 'A. Writer' } });
    }
    const seen = [];
    let path = '/books?limit=2&q=paged';
    while (path) {
      const res = await send(path);
      const { data, page } = await res.json();
      assert.ok(data.length <= 2);
      seen.push(...data.map((b) => b.title));
      assert.equal(res.headers.get('link'), page.next && `<${page.next}>; rel="next"`);
      path = page.next;
    }
    assert.deepEqual(seen, ['Paged 0', 'Paged 1', 'Paged 2']);
    assert.equal((await send('/books?limit=1000')).status, 400);
    assert.equal((await send('/books?cursor=garbage')).status, 400);
  });

  it('treats LIKE wildcards in q literally', async () => {
    const { data } = await (await send('/books?q=%25')).json();   // q is a literal percent sign
    assert.deepEqual(data, []);
  });

  it('implements CORS for an allow-listed origin only', async () => {
    const preflight = await send('/books', {
      method: 'OPTIONS',
      headers: { Origin: 'https://app.example', 'Access-Control-Request-Method': 'POST' },
    });
    assert.equal(preflight.status, 204);
    assert.equal(preflight.headers.get('access-control-allow-origin'), 'https://app.example');
    assert.match(preflight.headers.get('access-control-allow-methods'), /POST/);

    const evil = await send('/books', { headers: { Origin: 'https://evil.example' } });
    assert.equal(evil.status, 200);                                // CORS is enforced by browsers, not the server
    assert.equal(evil.headers.get('access-control-allow-origin'), null);
    assert.equal(evil.headers.get('vary'), 'Origin');
  });

  it('logs one line per request without leaking the body', async () => {
    logLines.length = 0;
    await send('/books', { method: 'POST', body: { isbn: '123', title: 'Secret Title', author: 'x' } });
    const access = logLines.filter((l) => l.msg === 'request');
    assert.equal(access.length, 1);
    assert.equal(access[0].status, 422);
    assert.ok(!JSON.stringify(access[0]).includes('Secret Title'));
  });
});

describe('graceful shutdown', () => {
  it('finishes the in-flight request, then refuses new connections', async () => {
    const config = loadConfig({ PORT: '0', DB_PATH: ':memory:' });
    const app = createApp(config, { log: createLogger({ level: 'silent' }) });
    const { port } = await app.start();

    // Start a POST but hold back the end of the body, so the request is in flight.
    const payload = JSON.stringify({ isbn: ISBNS[0], title: 'Drained', author: 'A. Writer' });
    const req = http.request({ port, host: '127.0.0.1', method: 'POST', path: '/books',
      headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } });
    req.write(payload.slice(0, 10));
    await new Promise((resolve) => setTimeout(resolve, 50));

    const stopped = app.stop();                                   // begins draining
    await new Promise((resolve) => setTimeout(resolve, 50));
    await assert.rejects(fetch(`http://127.0.0.1:${port}/health`)); // listener is closed: new connections fail

    req.end(payload.slice(10));
    const [res] = await once(req, 'response');
    assert.equal(res.statusCode, 201);                            // the in-flight request still completed
    res.resume();
    const started = performance.now();
    await stopped;                                                // resolves once the connection is swept
    assert.ok(performance.now() - started < 2000, 'did not wait for keep-alive timeouts');
  });
});
Running the suite (output from Node.js 26.10.0, durations rounded)
node --test
▶ Bookshelf REST API
  ✔ creates, reads, replaces and deletes a book
  ✔ answers errors as problem details
  ✔ rejects bad bodies before they reach the database
  ✔ answers 404, 405 and OPTIONS with the right headers
  ✔ returns 409 for a duplicate ISBN
  ✔ paginates with an opaque cursor and a Link header
  ✔ treats LIKE wildcards in q literally
  ✔ implements CORS for an allow-listed origin only
  ✔ logs one line per request without leaking the body
✔ Bookshelf REST API
▶ graceful shutdown
  ✔ finishes the in-flight request, then refuses new connections
✔ graceful shutdown
ℹ tests 10
ℹ pass 10
ℹ fail 0

The same ten tests passed on Node.js 24.21.0, and on 22.23.3 with the URLPattern preload described earlier.

The structure of the tests is as important as their content.

  • Port 0 and address(). Each suite listens on a free port and learns the real one from start(), so tests never collide with a running service or with each other, in parallel CI jobs or on a laptop.

  • One application per suite, an in-memory database. before() creates it, after() calls the same stop() that production uses, which doubles as a test of the shutdown path. Because the tests in a describe share state (the table), each test creates its own distinct ISBNs and the ordering assumptions are explicit: the pagination test searches for the title prefix it created itself.

  • fetch() as the client. It is the same client the real consumers use, so the tests see exactly what they would see: header names are case-insensitive, bodies are streams, and an abandoned connection rejects.

  • Test the failure modes as carefully as the successes. Most of the file asserts error responses: the problem media type, the list of pointers, Allow, Connection: close after a 413, 409 for a duplicate. These are the behaviours client developers depend on.

  • Test for the absence of leaks. The logging test asserts that a submitted value does not appear in the access log. Negative assertions like this one protect security properties that would otherwise only be noticed in an incident.

  • Test the shutdown with a request in flight. The raw http.request() writes part of the body, the test begins stop(), checks that new connections are refused, finishes the body and asserts that the request still completed with 201 and that shutdown then finished quickly. That test is what exposed the idle-connection problem described above: without the sweep it fails the two-second assertion.

  • node:assert/strict. The strict mode uses strict equality and deep strict equality for equal and deepEqual, so assert.equal(1, '1') fails.

Coverage can be enabled with node --test --experimental-test-coverage (the coverage section of the test runner is Experimental) and reports which lines of src/ the suite touched; see Mocking, Snapshots & Coverage. The unit tests of pure functions such as isValidIsbn13() and parseListQuery() are cheap additions in the same style, and Test Runner shows how to run them in parallel, with hooks and reporters.

Pitfalls checklist

Table 6. Common mistakes when building an API on node:http
Mistake What to do instead

Buffering a request body with no limit (for await with no counter, or json() from node:stream/consumers on raw input)

Check Content-Length, count chunks as they arrive, answer 413 and close the connection.

Matching routes with req.url === '/books'

req.url includes the query string. Parse it with new URL() and match url.pathname with URLPattern.

Using the Host header or req.url as a trusted absolute URL

Parse origin-form targets only, and build absolute URLs from configuration.

res.end() for errors with a different shape in every handler

One HttpError type, one catch, one problem details shape.

Echoing err.message of unexpected errors to the client

Log the error with the request id; answer a generic 500.

Reflecting the Origin header, or Access-Control-Allow-Origin: * together with cookies or tokens

Allow-list exact origins, add Vary: Origin, and remember that CORS is not authentication.

Concatenating user input into SQL, or building the LIKE pattern without escaping

Bind parameters with ? and escape the wildcards you do not want.

OFFSET pagination on large tables, or COUNT(*) on every page

Keyset pagination with an opaque, validated cursor and a limit + 1 fetch.

Check-then-insert for uniqueness

A UNIQUE constraint, with the violation mapped to 409.

An async request listener with no try/catch

Catch everywhere inside the listener; an unhandled rejection can terminate the process.

process.exit() in a SIGTERM handler

Stop the server, wait for 'close', release resources and let the process exit by itself, with a deadline.

Logging bodies, tokens or full URLs with query strings

Log method, path, status, duration and ids; keep secrets out of every log.

Tests that call handlers directly

Start the server on port 0 and call it over HTTP, so routing, headers and error handling are covered.

What changed recently

Table 7. Changes that affect how this API is written
Change Version Effect on the code

URLPattern is a global

v24.0.0 (class in node:url since v23.8.0)

No router package is needed on 24 and later; 22 needs a polyfill. It is still Stability 1 Experimental on 26.

node:sqlite needs no flag

v23.4.0 and v22.13.0; release candidate since v25.7.0

A zero-dependency database for services. Node.js 22 still prints an ExperimentalWarning.

--env-file-if-exists, and --env-file no longer experimental

Added v22.9.0; stable in v24.10.0 and v22.21.0

dotenv is unnecessary for plain KEY=value files.

server.close() closes idle connections

v19.0.0

Removes the need to track keep-alive sockets by hand; a busy connection still needs the sweep shown above.

requestTimeout default 300 s, headersTimeout default min(60 s, requestTimeout)

v18.0.0, v19.4.0 and v18.14.0

Slow clients are cut off by default; tune them for your deployment.

response.writeHead() returns this

v11.10.0

res.writeHead(204).end() chains.

http.STATUS_CODES[422] is still "Unprocessable Entity"

Observed on 26.10.0

RFC 9110 renamed the status "Unprocessable Content"; use your own title strings in problem documents.

RFC 9457 obsoletes RFC 7807

2023

application/problem+json is unchanged; new specifications cite 9457.

message.signal (an AbortSignal that aborts when the client disconnects)

v26.1.0, and v24.16.0 on the 24 line

A handler can stop slow work when the client goes away; see HTTP Server Fundamentals. This service does not need it because every handler is short.

await using for servers (server[Symbol.asyncDispose]())

v20.4.0, stable in v24.2.0

Tests can dispose of a server with await using on 24 and later; the example uses explicit stop() so that it also parses on 22.

node:test and node:assert/strict need no installation, and Mocha, Jest or Supertest are optional

Stable (see the version map)

End-to-end tests of an HTTP service require nothing but fetch().

Where the books are outdated

Two books on the reading list touch the topics of this page, and both predate or narrow the approach shown here. In Efficient Node.js (Samer Buna, O’Reilly, 2025) the chapter on practical Node.js shows routing in a plain node:http server as a switch over req.url (and a nested switch over req.method) and presents that as the reason to adopt a framework. Matching req.url as a string is indeed fragile, because the value includes the query string and is not normalised, but the conclusion is dated: URLPattern (a global since Node.js 24) provides pattern matching with named groups, and the combination of new URL() and a small method table, as above, gives correct 404 and 405 handling in a few lines. The same chapter reaches for express.json() and morgan for body parsing and logging; this page shows what those do (a size-limited body reader and an access log) so the trade-off is visible, and Web Frameworks: Express & Fastify covers Express 5 and Fastify 5 for readers who want the framework route.

Node.js Projects (Jonathan Wexler, O’Reilly, 2025) builds its library REST API with Fastify and an ORM (Sequelize) on SQLite, and its not-found handler and its table-creation option are explained in ways that do not match the official behaviour (a not-found handler should answer 404, and forcing a table to be recreated is not change detection and discards the data). Both rest on third-party layers that this page replaces with URLPattern and node:sqlite, which have been usable without flags since Node.js 24 and 23.4 respectively. Neither book describes RFC 9457 problem details, keyset pagination, a CORS allow-list, request-id correlation or a graceful shutdown with a deadline, and the Fastify-based project in the second book is based on Fastify 4 patterns, a line that reached end of life on 2025-06-30, so plugin versions must be matched to Fastify 5. Neither book’s examples handle SIGTERM. Everything here is checked against the current official documentation, which wins wherever a book differs.

Where to go next

References