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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
|
|
1 Experimental |
Added to |
|
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 |
|
2 Stable (the test runner; its coverage and watch sections are Experimental) |
|
Global |
2 Stable |
Used as the test client and for request ids and timings. |
|
2 Stable |
|
|
Stable |
Added in v22.9.0; no longer experimental in v24.10.0 and v22.21.0. It silently skips a missing file, unlike |
Source: Node.js API: HTTP, Node.js API: URLPattern, Node.js API: SQLite, Node.js API: Command-line options
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.
| Method and path | Purpose | Success and error statuses |
|---|---|---|
|
List books, newest ids last, with |
|
|
Create a book from a JSON body. |
|
|
Read one book. |
|
|
Replace a book (every field is sent again). |
|
|
Delete a book. |
|
|
Liveness and readiness probes. Readiness answers |
|
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.
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.
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.
// 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.
// 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.
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).
| Situation | Status | Reasoning |
|---|---|---|
Body is not valid JSON, or the query string is malformed |
|
The request could not be understood at all; nothing was validated. |
Path matches no route, or the resource does not exist |
|
|
Known path, unsupported method |
|
Must carry an |
The ISBN already exists |
|
The request is valid but conflicts with the current state of the resource. |
Body larger than the limit |
|
Sent before the body is read in full; the connection is closed afterwards. |
Body is not |
|
The server will not interpret that content type. |
Well-formed JSON with invalid fields |
|
Syntax is fine, semantics are not. RFC 9110 names it "Unprocessable Content"; |
An unexpected exception |
|
The detail is replaced by a generic message; the real error goes to the log with the same |
The service is draining |
|
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-8passes andtext/plainis answered with415before any byte of the body is read. -
Declared size first. A
Content-Lengthabove 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: closeon a413. 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 thecause, not echoed to the client. -
Decode after concatenating. Chunk boundaries can split a multi-byte character, so the chunks are joined as
Bufferfirst 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
pointerto the offending field and adetail. -
Reject unknown fields. A typo such as
titelis reported rather than silently ignored, and no field the server did not plan for is ever stored. -
Check types before ranges.
typeoffirst, then length or numeric range;Number.isInteger()rejects2008.5and"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.
limitis bounded to 1-100 and thecursormust be one this API issued.
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');
Source: Node.js API: Buffer encodings (base64url), Node.js API: URLSearchParams, RFC 9457: Extension Members
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):
{
"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).
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,
};
}
Source: Node.js API: sqlite.DatabaseSync, Node.js API: sqlite.StatementSync, SQLite: STRICT tables, SQLite: RETURNING, SQLite: PRAGMA statements
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. ThePRAGMAtext is built from a loop counter that the code owns, never from input, because aPRAGMAcannot take bound parameters. -
STRICTtables make SQLite enforce column types (without it, SQLite stores any value in any column), and theUNIQUEconstraint onisbnis the duplicate guard. -
journal_mode = WALlets 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: 5000makes 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
StatementSyncobjects 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. -
RETURNINGgives the inserted or updated row in the same statement, so no second query (and no race) is needed to read it back. Note the aliascreated_at AS createdAt: the column names of the API are decided in the SQL. -
LIKEwildcards are data. A search for%should find a literal percent sign, so the search text is escaped and the statement saysESCAPE '\'. -
Mapping the constraint error. A unique violation surfaces as an error whose
codeisERR_SQLITE_ERROR(documented in the errors page) and which, in the runs for this page, also carriederrcode2067 (SQLite’sSQLITE_CONSTRAINT_UNIQUE) anderrstr. 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.
| Aspect | Offset (?page=40&size=20) |
Keyset (?cursor=…&limit=20) |
|---|---|---|
Query |
|
|
Cost for a deep page |
The database walks and discards every skipped row. In a check with |
It seeks straight to the position. The keyset form reported |
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 + 1rows. The extra row answers "is there a next page?" without a separateCOUNT(*), 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.nextfor clients that read JSON and theLinkheader withrel="next"for generic HTTP tooling. Both are relative references that already containlimitand anyq, 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.
// 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: nullplus theallowlist, which becomes a405with anAllowheader. Without this distinction a wrong method looks like a missing page, which is misleading for client developers. -
No such path:
null, which becomes404.
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.
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 answers200without CORS headers). -
A request that is not a "simple" request (for example
POSTwithContent-Type: application/json, orPUT, orDELETE) is preceded by a preflight: the browser sendsOPTIONSwithOriginandAccess-Control-Request-Method, and only if the answer allows it sends the real request.
// 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
Originheader is copied intoAccess-Control-Allow-Originonly when it is in the configuredCORS_ORIGINSset. Echoing any origin back is equivalent to*and, combined with credentials, hands every site your users' sessions. The stringnull(sent by sandboxed documents and some redirects) is never in the set unless you add it, and you should not. -
Vary: Originalways. 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-Methodslists what the route supports (taken from the router, so it cannot drift),Access-Control-Allow-Headerslists onlyContent-Type, andAccess-Control-Max-Age: 600lets 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,LinkandX-Request-Idare listed inAccess-Control-Expose-Headers; without that, the paginationLinkheader 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 anAuthorizationheader. This API sets no cookies, so it does not enableAccess-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):
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
Source: Fetch Standard: CORS protocol
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.
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 createslog.child({ requestId })per request, so every line written while handling that request, including the error line, carries the id the client sees inX-Request-Idand 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.
Errorproperties are non-enumerable, so a plainJSON.stringify(err)prints{}; the logger copiesname,messageandstack. Stack traces go to the log and never to the client. -
What is deliberately not logged: request bodies,
Authorizationand 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).
{"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.
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 };
}
Source: Node.js API: http.createServer(), Node.js API: Event 'clientError', Node.js Learn: Anatomy of an HTTP Transaction
The notes below follow the numbered comments in handle().
| Stage | Code | Reason for the position |
|---|---|---|
1 |
Request id (accepted from the client only if it matches a conservative pattern, else |
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. |
2 |
|
|
3 |
Request target check and |
Everything after this point can use a trusted |
4 |
|
Decides 404 versus 405 before spending any effort on the body. |
5 |
|
A preflight must be answered before the method check, because |
6 |
The handler |
Receives only what it needs: |
7 |
|
The only place that turns exceptions into responses. Unknown errors become |
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.
The mechanism is the stop() function at the end of app.js above and the entry point that wires signals to it.
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.
-
drainingfirst. The readiness probe starts answering503so a load balancer stops sending traffic, and responses to requests that are already on keep-alive connections carryConnection: 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 whystop()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 withConnection: 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 secondkeepAliveTimeoutBuffer; with the sweep it took 101 ms (both observed on Node.js 26.10.0). The documentation saysclose()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 callscloseAllConnections(), 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 showeddraining, then the warningshutdown timeout, closing remaining connections500 ms later, thenstopped, the stalled request logged withcompleted: falseandclient went away, and the process exited with code 0. The timer isunref()-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 withprocess.exitCode. An explicitprocess.exit()could truncate buffered log output on a pipe and hides leaks of open handles that a clean exit would reveal. The onlyprocess.exit(1)is the impatient operator’s second signal. -
Platform note. Windows has no real signals: the documentation says sending
SIGTERMto 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.
{
"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
Source: Node.js API: --env-file, npm Docs: scripts
With the port from the first log line (here 52380) in BASE, a session with curl exercises the whole API.
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
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).
{"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.
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');
});
});
Source: Node.js API: test.describe(), Node.js API: before(), Node.js API: assert.rejects(), Node.js Learn: Using Node.js’s test runner
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
0andaddress(). Each suite listens on a free port and learns the real one fromstart(), 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 samestop()that production uses, which doubles as a test of the shutdown path. Because the tests in adescribeshare 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: closeafter a413,409for 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 beginsstop(), checks that new connections are refused, finishes the body and asserts that the request still completed with201and 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 forequalanddeepEqual, soassert.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
| Mistake | What to do instead |
|---|---|
Buffering a request body with no limit ( |
Check |
Matching routes with |
|
Using the |
Parse origin-form targets only, and build absolute URLs from configuration. |
|
One |
Echoing |
Log the error with the request id; answer a generic |
Reflecting the |
Allow-list exact origins, add |
Concatenating user input into SQL, or building the |
Bind parameters with |
|
Keyset pagination with an opaque, validated cursor and a |
Check-then-insert for uniqueness |
A |
An |
Catch everywhere inside the listener; an unhandled rejection can terminate the process. |
|
Stop the server, wait for |
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 |
What changed recently
| Change | Version | Effect on the code |
|---|---|---|
|
v24.0.0 (class in |
No router package is needed on 24 and later; 22 needs a polyfill. It is still Stability 1 Experimental on 26. |
|
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 |
|
Added v22.9.0; stable in v24.10.0 and v22.21.0 |
|
|
v19.0.0 |
Removes the need to track keep-alive sockets by hand; a busy connection still needs the sweep shown above. |
|
v18.0.0, v19.4.0 and v18.14.0 |
Slow clients are cut off by default; tune them for your deployment. |
|
v11.10.0 |
|
|
Observed on 26.10.0 |
RFC 9110 renamed the status "Unprocessable Content"; use your own |
RFC 9457 obsoletes RFC 7807 |
2023 |
|
|
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. |
|
v20.4.0, stable in v24.2.0 |
Tests can dispose of a server with |
|
Stable (see the version map) |
End-to-end tests of an HTTP service require nothing but |
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
-
Web Frameworks: Express & Fastify: the same API with Express 5 and Fastify 5, JSON Schema validation and
inject()tests. -
Authentication & Sessions: protect the write routes with sessions or tokens, CSRF defences and rate limiting.
-
Production Deployment and Docker & Containers: run it behind a reverse proxy, in a container, with probes.
-
WebSockets & Server-Sent Events: add the live "new review" feed to the same server.
-
Databases from Node.js: replace the repository with PostgreSQL when one node is no longer enough.
-
NestJS: the structured, decorator-based framework that provides these pieces (pipes, filters, interceptors, lifecycle hooks) for you.
References
-
Node.js Learn: Anatomy of an HTTP Transaction, https://nodejs.org/learn/http/anatomy-of-an-http-transaction
-
Node.js Learn: Using Node.js’s test runner, https://nodejs.org/learn/test-runner/using-test-runner
-
Node.js API: HTTP, https://nodejs.org/docs/latest/api/http.html
-
Node.js API: http.createServer(), https://nodejs.org/docs/latest/api/http.html#httpcreateserveroptions-requestlistener
-
Node.js API: server.close(), https://nodejs.org/docs/latest/api/http.html#serverclosecallback
-
Node.js API: server.closeIdleConnections(), https://nodejs.org/docs/latest/api/http.html#servercloseidleconnections
-
Node.js API: server.closeAllConnections(), https://nodejs.org/docs/latest/api/http.html#servercloseallconnections
-
Node.js API: server.requestTimeout, https://nodejs.org/docs/latest/api/http.html#serverrequesttimeout
-
Node.js API: server.headersTimeout, https://nodejs.org/docs/latest/api/http.html#serverheaderstimeout
-
Node.js API: server.keepAliveTimeout, https://nodejs.org/docs/latest/api/http.html#serverkeepalivetimeout
-
Node.js API: Event 'clientError', https://nodejs.org/docs/latest/api/http.html#event-clienterror
-
Node.js API: response.writeHead(), https://nodejs.org/docs/latest/api/http.html#responsewriteheadstatuscode-statusmessage-headers
-
Node.js API: response.setHeader(), https://nodejs.org/docs/latest/api/http.html#responsesetheadername-value
-
Node.js API: response.writableFinished, https://nodejs.org/docs/latest/api/http.html#responsewritablefinished
-
Node.js API: http.STATUS_CODES, https://nodejs.org/docs/latest/api/http.html#httpstatus_codes
-
Node.js API: message.headers, https://nodejs.org/docs/latest/api/http.html#messageheaders
-
Node.js API: URLPattern, https://nodejs.org/docs/latest/api/url.html#class-urlpattern
-
Node.js API: Globals, URLPattern, https://nodejs.org/docs/latest/api/globals.html#class-urlpattern
-
Node.js API: SQLite, https://nodejs.org/docs/latest/api/sqlite.html
-
Node.js API: sqlite.DatabaseSync, https://nodejs.org/docs/latest/api/sqlite.html#class-databasesync
-
Node.js API: sqlite.StatementSync, https://nodejs.org/docs/latest/api/sqlite.html#class-statementsync
-
Node.js API: Test runner, https://nodejs.org/docs/latest/api/test.html
-
Node.js API: Assert, https://nodejs.org/docs/latest/api/assert.html
-
Node.js API: Process, https://nodejs.org/docs/latest/api/process.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Errors, https://nodejs.org/docs/latest/api/errors.html
-
Node.js API: Crypto, crypto.randomUUID(), https://nodejs.org/docs/latest/api/crypto.html#cryptorandomuuidoptions
-
Node.js API: Performance measurement APIs, https://nodejs.org/docs/latest/api/perf_hooks.html#performancenow
-
Node.js API: Buffer, https://nodejs.org/docs/latest/api/buffer.html
-
npm Docs: scripts, https://docs.npmjs.com/cli/v11/using-npm/scripts
-
RFC 9457: Problem Details for HTTP APIs, https://www.rfc-editor.org/rfc/rfc9457.html
-
RFC 9110: HTTP Semantics, https://www.rfc-editor.org/rfc/rfc9110.html
-
Fetch Standard: CORS protocol, https://fetch.spec.whatwg.org/#http-cors-protocol
-
URL Pattern Standard, https://urlpattern.spec.whatwg.org/
-
SQLite: STRICT tables, https://www.sqlite.org/stricttables.html
-
SQLite: RETURNING, https://www.sqlite.org/lang_returning.html
-
SQLite: PRAGMA statements, https://www.sqlite.org/pragma.html
-
SQLite: LIKE, GLOB, REGEXP, https://www.sqlite.org/lang_expr.html#the_like_glob_regexp_match_and_extract_operators
-
urlpattern-polyfill (third-party), https://github.com/kenchris/urlpattern-polyfill
-
Fastify: Long Term Support, https://fastify.dev/docs/latest/Reference/LTS/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/