HTTP Server Fundamentals
|
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 every example below was run on Node.js 26 and 24, and on 22 unless the text says
otherwise. HTTP is the protocol most Node.js programs speak, and the node:http module (Stability 2, Stable) is the foundation under every
framework in the ecosystem: Express, Fastify and NestJS all receive an IncomingMessage and write to a ServerResponse. Learning that
layer once explains their behaviour, their error messages and their performance limits. The module is deliberately low level: it parses a
message into a start line, headers and a body, and it never buffers a whole request or response unless you ask it to; everything else (routing, body
parsing, validation, cookies, content negotiation) is yours to build or to borrow from a framework, and
Web Frameworks: Express & Fastify shows what that borrowing looks like. The
running scenario is the Bookshelf application that grows across this section, described in
Introduction & Architecture; this page ends with its first complete server.
The stability and version map
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
|
|
1 Experimental |
Class added to |
|
Stable (part of the module) |
Added in v26.1.0 and available on Node.js 24 since v24.16.0 (observed on 24.21.0); absent on 22. Since v26.7.0 and v24.20.0 it is no longer aborted when the message completes normally. |
|
Stable |
|
|
Stable |
Added in v20.4.0, no longer experimental in v24.2.0; needs a runtime that parses |
|
Stable |
Keep-alive and a 5 second timeout by default since v19.0.0. |
|
Stable |
Server property added in v24.6.0 and v22.19.0; the Agent option in v24.7.0 and v22.20.0. |
|
Stable |
Added in v26.2.0; the v24.x documentation lists it from v24.18.0. |
|
Stable |
Added in v26.3.0 and v24.19.0; not in the 22 documentation. |
|
End-of-Life (DEP0063) |
Removed in v26.0.0: use |
Built-in proxy support ( |
1.1 Active development |
Added in v24.5.0 and v22.21.0; |
Anatomy of an HTTP transaction
An HTTP/1.1 transaction is a text conversation over a TCP connection: the client sends a start line (method, request target, protocol version),
header fields, an empty line and an optional body; the server answers with a status line, header fields, an empty line and an optional body.
Node.js implements the server side in layers. libuv accepts the TCP connection and net.Server hands the socket to http.Server, which feeds the
bytes to the llhttp parser (see Introduction & Architecture); when the headers are complete,
the server emits a 'request' event with two objects, an IncomingMessage for the request and a ServerResponse for the reply. Your request
listener runs on the main thread, and from then on the server is only as fast as that listener is non-blocking (see
Don’t Block the Event Loop).
The same conversation can be watched at the lowest level with a raw TCP client that writes the request text by hand. The server below is
ordinary node:http code, and the client is node:net, so nothing hides the bytes.
import http from 'node:http';
import net from 'node:net';
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('pong\n');
});
server.listen({ port: 0, host: '127.0.0.1' }, () => {
const { port } = server.address();
// A raw TCP client: write the request text by hand and print whatever comes back
const socket = net.connect(port, '127.0.0.1');
socket.write('GET /ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
let raw = '';
socket.setEncoding('utf8').on('data', (chunk) => (raw += chunk));
socket.on('close', () => {
console.log(raw.replaceAll('\r\n', '\\r\\n\n'));
server.close();
});
});
HTTP/1.1 200 OK\r\n
Content-Type: text/plain; charset=utf-8\r\n
Date: Sat, 03 Oct 2026 22:41:43 GMT\r\n
Connection: close\r\n
Content-Length: 5\r\n
\r\n
pong
Three details in that output are worth reading closely. The request carried Host because HTTP/1.1 requires it (the server answers 400 when it
is missing, controlled by the requireHostHeader option, which defaults to true). The server added Date and Content-Length itself,
because the handler used setHeader() and end(data) and Node.js could count the bytes. And it echoed Connection: close because the client
asked for it; without that request header the connection would have stayed open for the next request.
| Part of the message | Request side (IncomingMessage) |
Response side (ServerResponse) |
|---|---|---|
Start line |
|
|
Header fields |
|
|
Body |
The request object itself is a Readable stream |
|
Trailers |
|
|
Connection |
|
|
http.createServer and the request listener
http.createServer([options][, requestListener]) returns an http.Server. The listener is simply registered for the 'request' event, so the two
forms below are equivalent; the Learn guide points out that the server is an EventEmitter and the listener argument is shorthand. The listener
is called once for every request, and one connection can carry many requests in sequence when keep-alive is in use.
import http from 'node:http';
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Hello from the Bookshelf\n');
});
// port 0 asks the operating system for any free port
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const { port } = server.address();
console.log(`listening on http://127.0.0.1:${port}`);
const response = await fetch(`http://127.0.0.1:${port}/`);
console.log(response.status, response.headers.get('content-type'));
console.log(await response.text());
server.close();
});
listening on http://127.0.0.1:<port>
200 text/plain; charset=utf-8
Hello from the Bookshelf
The same server written with an explicit listener looks like this; it is useful when several modules need to attach listeners to one server.
import http from 'node:http';
const server = http.createServer();
server.on('request', (req, res) => {
res.end('same behaviour\n');
});
The listener receives two objects whose classes are worth knowing precisely, because they decide which stream tools work on them.
-
http.IncomingMessageextendsstream.Readable. It is created separately from the socket (which is aDuplex) because a keep-alive socket serves many messages in turn. Everything from the streams page applies: async iteration,pipeline(),'end'and'close', backpressure. -
http.ServerResponseextendshttp.OutgoingMessage, which extends the legacyStreamclass. It has the Writable interface (write(),end(),'drain','finish','close') and works as apipeline()destination, but it is not astream.Writablesubclass: on Node.js 26,res instanceof Writableisfalse(observed), so code that checks the class will be surprised. -
The server is a
net.Server, solisten(),address(),close()and the'listening','connection'and'error'events all come from the TCP layer. The streams side is described in Streams.
createServer() also takes an options object as its first argument. The options that matter most day to day are below; limits and timeouts
are treated in depth in
Server Timeouts, Limits & Hardening.
| Option | Default | Meaning |
|---|---|---|
|
|
Time allowed to receive the complete headers; on expiry the server answers 408 and closes the connection. |
|
|
Time allowed to receive the entire request; default changed from "no timeout" in v18.0.0. |
|
|
Idle time a server waits for another request on a kept-alive socket. |
|
|
Maximum request header size in bytes; overrides |
|
|
Answer 400 to any HTTP/1.1 request without a |
|
|
Join duplicate request header lines with a comma instead of discarding the repeats (v19.5.0, v18.14.0). |
|
|
Header value validation level: |
|
|
Lenient parser; the documentation says its use should be avoided. |
|
|
Disable Nagle’s algorithm on new connections; default changed to |
|
See |
Overrides the socket, request and response |
|
|
Give body-less requests an already-ended body stream so they never emit |
|
The built-in classes |
Substitute subclasses; useful for adding helpers such as |
Source: Node.js API: http.createServer()
The documentation lists a default of 65000 for the keepAliveTimeout option but documents server.keepAliveTimeout as 5000. The value
that every run for this page printed on Node.js 26, 24 and 22 was 5000 for the property, and a server answered Keep-Alive: timeout=5. That is
observed behaviour; if you depend on a specific idle timeout, set it explicitly.
listen, port 0 and the listening address
server.listen() is inherited from net.Server. The options form is the clearest: host selects the interface, backlog the pending
connection queue, and signal takes an AbortSignal that closes the listening server when aborted. Passing port: 0 (or omitting the port)
asks the operating system for any free port, which you read back from server.address().port once 'listening' has fired. That is how tests
and parallel jobs avoid "address already in use" collisions, and why every example on this page binds to port 0.
import http from 'node:http';
import { once } from 'node:events';
const server = http.createServer((req, res) => res.end('ok'));
const stop = new AbortController();
server.listen({ port: 0, host: '127.0.0.1', signal: stop.signal }); // port 0: the OS picks a free port
await once(server, 'listening'); // address() is null before this
const { address, family, port } = server.address();
console.log({ address, family, portIsNumber: typeof port === 'number' && port > 0, listening: server.listening });
console.log((await fetch(`http://127.0.0.1:${port}/`)).status);
stop.abort(); // closes the listening server
await once(server, 'close');
console.log('listening after abort:', server.listening);
server.closeAllConnections(); // drop fetch's idle socket
{
address: '127.0.0.1',
family: 'IPv4',
portIsNumber: true,
listening: true
}
200
listening after abort: false
Two practical notes on addresses. If host is omitted, the server listens on the unspecified IPv6 address :: when IPv6 is available (which on
most systems also accepts IPv4), and on 0.0.0.0 otherwise, so a development server started without a host is reachable from other machines on the
network; pass host: '127.0.0.1' when you mean "this machine only". And server.address() returns null before the 'listening' event, which is why
the example awaits the event before reading it.
The request: IncomingMessage
The first things a handler looks at are the method, the target and the headers. req.url is only what appeared on the request line: a path and a
query string, such as /books?genre=fantasy, without scheme, host or port. To take it apart, give the WHATWG URL class a base, as the
http documentation recommends; ideally the base host comes from configuration, because the Host header is supplied by the client and must be
validated before it is trusted.
import http from 'node:http';
const server = http.createServer((req, res) => {
const { method, url, httpVersion, headers, rawHeaders } = req;
// req.url is only the path and query; give URL a base so it can parse it
const parsed = new URL(url, 'http://localhost');
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
method,
httpVersion,
path: parsed.pathname,
genre: parsed.searchParams.get('genre'),
userAgent: headers['user-agent'],
accept: headers.accept,
rawHeaderNames: rawHeaders.filter((_, i) => i % 2 === 0), // names exactly as the client sent them
peer: req.socket.remoteAddress,
}, null, 2));
});
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/books?genre=fantasy&genre=sci-fi`, {
headers: { Accept: 'application/json', 'X-Request-ID': 'abc-123' },
});
console.log(await response.text());
server.close();
});
{
"method": "GET",
"httpVersion": "1.1",
"path": "/books",
"genre": "fantasy",
"userAgent": "node",
"accept": "application/json",
"rawHeaderNames": [
"host",
"connection",
"Accept",
"X-Request-ID",
"accept-language",
"sec-fetch-mode",
"user-agent",
"accept-encoding"
],
"peer": "127.0.0.1"
}
Header names in req.headers are always lower-case, whatever the client sent, while req.rawHeaders keeps the original spelling in a flat
[name, value, name, value] array. Duplicates are handled by name, which matters for security-sensitive headers.
| Header | Behaviour |
|---|---|
|
Duplicates are discarded. Set |
|
Always an array. |
|
Duplicates are joined with `; `. |
Every other header |
Duplicates are joined with `, `. |
Source: Node.js API: message.headers
req.headersDistinct gives every header as an array of its separate values, which avoids the joining rules altogether. A raw-socket run on
Node.js 26 and 22 (observed) with two X-Tag lines, a and b, produced 'a, b' in req.headers['x-tag'] and ['a', 'b'] in
req.headersDistinct['x-tag']. Two properties of the request object are also easy to overlook: req.socket.remoteAddress is the address of the
immediate peer, which behind a reverse proxy is the proxy (see the proxy discussion in
Server Timeouts, Limits & Hardening), and req.complete tells you whether a full
message was received before the connection ended.
Reading the request body as a stream
Because IncomingMessage is a Readable, the body arrives in chunks as the client sends it, and the handler may start working before the last byte
is in. The Learn guide shows the classic recipe: collect Buffer chunks from the 'data' event and concatenate them on 'end'. In current code
the same job is a for await loop, which also gives you a natural place to enforce a size limit and turns stream errors into ordinary exceptions.
import http from 'node:http';
import { json } from 'node:stream/consumers';
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// Read a JSON body from the request stream, refusing bodies larger than `limit` bytes
async function readJson(req, limit = 1024) {
if (Number(req.headers['content-length']) > limit) {
throw new HttpError(413, 'Payload Too Large');
}
const chunks = [];
let size = 0;
for await (const chunk of req) { // IncomingMessage is a Readable stream
size += chunk.length;
if (size > limit) throw new HttpError(413, 'Payload Too Large');
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw new HttpError(400, 'Body is not valid JSON');
}
}
const server = http.createServer(async (req, res) => {
try {
if (req.method === 'POST' && req.url === '/books') {
const book = await readJson(req);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: book }));
} else if (req.method === 'POST' && req.url === '/consumers') {
// the same job in one line with the stream consumers (no size limit!)
res.end(JSON.stringify({ received: await json(req) }) + '\n');
} else {
throw new HttpError(404, 'Not Found');
}
} catch (err) {
const status = err.status ?? 500;
res.writeHead(status, { 'Content-Type': 'text/plain', Connection: 'close' });
res.end(`${status} ${err.status ? err.message : 'Internal Server Error'}\n`);
}
});
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const base = `http://127.0.0.1:${server.address().port}`;
const post = (path, body) => fetch(base + path, { method: 'POST', body })
.then(async (r) => `${r.status} ${await r.text()}`.trim());
console.log(await post('/books', JSON.stringify({ title: 'Dune' })));
console.log(await post('/books', '{ not json'));
console.log(await post('/books', 'x'.repeat(5000)));
console.log(await post('/consumers', JSON.stringify({ title: 'Emma' })));
server.close();
server.closeAllConnections();
});
201 {"received":{"title":"Dune"}}
400 400 Body is not valid JSON
413 413 Payload Too Large
200 {"received":{"title":"Emma"}}
Several design points are visible in that example.
-
Limit before you buffer. The handler checks the declared
Content-Lengthfirst (a cheap early refusal) and then counts bytes as chunks arrive, because a chunked body has no length header and the declared value can be a lie. An unboundedreadFile-style buffering of a request body is a memory-exhaustion vulnerability. A run that streamed ten 512-byte chunks to a handler limited to 1024 bytes (observed on Node.js 26 and 22) still received the413response while the client was uploading. -
Separate the error types. A malformed body is the client’s fault (400), an oversized one is 413, a missing field is 422, and an unexpected exception is 500 whose message must not leak to the client.
-
json()andtext()fromnode:stream/consumersread a whole stream in one call. They are convenient, and they have no size limit, so use them only after another layer has bounded the body, or on trusted input. -
Character sets. Chunks are
Bufferinstances; concatenate them before decoding, because a multi-byte character can be split across chunks.req.setEncoding('utf8')handles that for you when you want strings.
When you do not need the body at all, you can simply not read it: in a run on Node.js 26 and 22 (observed), three POST requests of 2 MB each to a handler that never
read the body shared one keep-alive connection, so Node.js discards an unread body once the response has finished. When
you only need to move the body somewhere, do not buffer it: the request is a stream, and pipeline() carries the bytes with backpressure. The Learn guide
ends with an echo server; the streaming version below never holds more than a few chunks in memory, however large the upload.
import http from 'node:http';
import { pipeline } from 'node:stream/promises';
const server = http.createServer(async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405, { Allow: 'POST' }).end();
return;
}
res.writeHead(200, { 'Content-Type': req.headers['content-type'] ?? 'application/octet-stream' });
try {
await pipeline(req, res); // bytes flow straight through; nothing is buffered
} catch {
res.destroy(); // the client vanished or the body was malformed
}
});
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const url = `http://127.0.0.1:${server.address().port}/`;
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: 'echo me' });
console.log(r.status, r.headers.get('content-type'), JSON.stringify(await r.text()));
console.log((await fetch(url)).status);
server.close();
server.closeAllConnections();
});
200 text/plain "echo me"
405
Request streams also emit 'error', and the Learn guide warns that an 'error' event with no listener is thrown and can crash the process. With
for await or pipeline() the error becomes a rejection you catch; with raw event listeners, attach an 'error' handler.
Cancelling work when the client leaves
A client can disconnect at any time: a closed browser tab, a mobile network drop, a timeout in a load balancer. On Node.js 26 (and 24 from v24.16.0)
req.signal is an AbortSignal that fires when the request is destroyed before completing or the socket closes before the handler is done. Passing it to
timers, fetch() or a database client stops wasted work. The signal is created lazily, so requests that never touch it pay nothing; since v26.7.0
(v24.20.0 on the 24 line) it is not aborted when the message completes normally.
import http from 'node:http';
import { setTimeout as sleep } from 'node:timers/promises';
const server = http.createServer(async (req, res) => {
try {
await sleep(5000, undefined, { signal: req.signal }); // req.signal: v26.1.0+
res.end('slow result\n');
} catch (err) {
console.log('server saw:', err.name); // AbortError when the client left
}
});
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const ac = new AbortController();
const pending = fetch(`http://127.0.0.1:${server.address().port}/`, { signal: ac.signal }).catch((e) => e.name);
await sleep(100);
ac.abort(); // the client gives up
console.log('client saw:', await pending);
await sleep(100);
server.close();
server.closeAllConnections();
});
client saw: AbortError
server saw: AbortError
Source: Node.js API: message.signal
On Node.js 22, which has no req.signal, listen for the response’s 'close' event and check res.writableFinished: when it is false, the connection
ended before the response was complete. That check printed finished normally: false for an aborted request on all three lines. The older 'aborted'
event and req.aborted property are deprecated in documentation (DEP0156); check stream.destroyed and listen for 'close' instead.
The response: status codes and headers
A response is a status code, headers and a body, in that order, and the order is enforced: once the head has gone out you cannot change it. The
ServerResponse offers two styles for the head.
-
Implicit headers. Assign
res.statusCode(it defaults to200), callres.setHeader()as often as you like, and the head is sent automatically with the firstwrite()orend(). Header names are case-insensitive, the last value set wins, and an array value sends several lines with the same name (Set-Cookie). Until the head is sent you can read it back withgetHeader()andgetHeaders(), and remove entries withremoveHeader(). -
Explicit headers.
res.writeHead(status[, statusMessage][, headers])sends the head right away and returns the response, sores.writeHead(204).end()chains. Headers set earlier withsetHeader()are merged, and those passed towriteHead()win. AfterwriteHead(),res.headersSentistrue.
The program below runs both styles and a failure on purpose: changing a header after the body has started throws ERR_HTTP_HEADERS_SENT.
import http, { STATUS_CODES } from 'node:http';
const server = http.createServer((req, res) => {
switch (req.url) {
case '/implicit': // headers are sent with the first write
res.statusCode = 201;
res.statusMessage = STATUS_CODES[201]; // optional: Node derives it from the code
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Set-Cookie', ['a=1; HttpOnly', 'b=2; HttpOnly']);
res.setHeader('X-Powered-By', 'bookshelf');
res.removeHeader('X-Powered-By');
res.end('created\n'); // Content-Length is computed for us
break;
case '/explicit': // writeHead sends the head immediately
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
console.log('headersSent after writeHead:', res.headersSent);
res.write('part one, ');
res.end('part two\n'); // no Content-Length known up front: chunked
break;
case '/no-content':
res.writeHead(204).end(); // writeHead() returns the response (v11.10+)
break;
case '/too-late':
res.end('body\n');
try {
res.setHeader('X-Too-Late', '1');
} catch (err) {
console.log(err.code); // ERR_HTTP_HEADERS_SENT
}
break;
default:
res.statusCode = 404;
res.end();
}
});
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const base = `http://127.0.0.1:${server.address().port}`;
for (const path of ['/implicit', '/explicit', '/no-content', '/too-late', '/nope']) {
const r = await fetch(base + path);
const body = await r.text();
const headers = [...r.headers].filter(([k]) => !['date', 'keep-alive', 'connection'].includes(k));
console.log(path, r.status, r.statusText, JSON.stringify(headers), JSON.stringify(body));
}
server.close();
server.closeAllConnections();
});
/implicit 201 Created [["content-length","8"],["content-type","text/plain; charset=utf-8"],["set-cookie","a=1; HttpOnly"],["set-cookie","b=2; HttpOnly"]] "created\n"
headersSent after writeHead: true
/explicit 200 OK [["cache-control","no-store"],["content-type","text/plain; charset=utf-8"],["transfer-encoding","chunked"]] "part one, part two\n"
/no-content 204 No Content [] ""
ERR_HTTP_HEADERS_SENT
/too-late 200 OK [["content-length","5"]] "body\n"
/nope 404 Not Found [["content-length","0"]] ""
Source: Node.js API: response.writeHead(), Node.js API: response.setHeader(), Node.js API: ERR_HTTP_HEADERS_SENT
The output also shows how Node.js frames the body. With implicit headers and end(data), it counted the bytes and added Content-Length. With
writeHead() followed by several writes there was no length, so the response was Transfer-Encoding: chunked. A raw-socket run on Node.js 26
(observed) showed that writeHead() followed by end(body) also produced a chunked response, while statusCode plus setHeader() plus end(body)
produced a Content-Length; if a client needs the length (for a progress bar, or a cache), set Content-Length yourself with
Buffer.byteLength(body) (it counts bytes, not characters). Setting res.strictContentLength = true makes Node.js throw
ERR_HTTP_CONTENT_LENGTH_MISMATCH when the header and the written bytes disagree.
| Code | Use it when | Notes for a Node.js handler |
|---|---|---|
|
A read or an update succeeded and you return a body. |
The default; you do not need to set it. |
|
A resource was created. |
Add |
|
Success with no body, such as a |
Do not write a body; with |
|
A conditional request found the cached copy valid. |
Compare |
|
The message is malformed (bad JSON, bad percent-encoding). |
Node.js sends this itself for unparsable HTTP. |
|
Not authenticated, or authenticated but not allowed. |
|
|
No route or resource matches. |
Also the safest answer for paths that must not reveal what exists. |
|
The path exists but not for this method. |
Required to carry an |
|
The body exceeded your limit. |
Send |
|
The JSON parsed but fails validation. |
Return the failing fields. |
|
Request headers exceeded |
Sent by Node.js itself. |
|
Your code failed, or the service is shutting down or overloaded. |
Never expose stack traces or messages in the body. |
Source: Node.js API: http.STATUS_CODES
http.STATUS_CODES maps every code to its reason phrase (http.STATUS_CODES[404] === 'Not Found'), and http.METHODS lists the methods the parser
understands. writeHead() throws a RangeError for a code outside 100 to 999. Header names and values are validated: a name or value containing
invalid characters throws a TypeError, so never copy untrusted text into a header without checking it (CRLF injection is the classic attack, and
http.validateHeaderName() and http.validateHeaderValue() expose the same checks).
Informational responses and trailers
A handler can send interim 1xx responses before the final one. response.writeEarlyHints() (v18.11.0) sends 103 with Link headers so a
browser can start fetching assets while the page is still being built; writeContinue() answers Expect: 100-continue (the server emits
'checkContinue' for those requests when you listen for it); writeProcessing() sends 102. Since v26.2.0 (v24.18.0 on the 24 line),
response.writeInformation(statusCode[, headers][, callback]) is the generic form for any 1xx code except 101, and it throws
ERR_HTTP_HEADERS_SENT once the final head is out. response.addTrailers() appends header fields after a chunked body, which is how a server can
send a checksum computed while streaming.
Streaming a response
Because res is a writable sink, a response can start before the data is complete: write rows as a database cursor produces them, or send a file
without reading it into memory. The cleanest way is to hand pipeline() a source and the response; it applies backpressure (the source pauses
when the socket is slow), and it destroys everything if either side fails. The example sends the catalogue as newline-delimited JSON (NDJSON),
one record per line, from an async generator. How the lines are grouped into chunks on the wire depends on timing, so the client may see them merged.
import http from 'node:http';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { setTimeout as sleep } from 'node:timers/promises';
// An async generator produces one JSON line per book; pipeline() applies backpressure
async function* catalogue() {
for (let i = 1; i <= 5; i++) {
yield JSON.stringify({ id: i, title: `Book ${i}` }) + '\n';
await sleep(20); // pretend each row comes from a slow query
}
}
const server = http.createServer(async (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' }); // no Content-Length: chunked
try {
await pipeline(Readable.from(catalogue()), res);
} catch (err) {
// ERR_STREAM_PREMATURE_CLOSE: the client went away; nothing more to send
if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') console.error(err);
}
});
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const response = await fetch(`http://127.0.0.1:${server.address().port}/`);
console.log(response.headers.get('transfer-encoding'));
for await (const chunk of response.body.pipeThrough(new TextDecoderStream())) {
console.log('chunk:', JSON.stringify(chunk));
}
server.close();
server.closeAllConnections();
});
chunked
chunk: "{\"id\":1,\"title\":\"Book 1\"}\n"
chunk: "{\"id\":2,\"title\":\"Book 2\"}\n"
chunk: "{\"id\":3,\"title\":\"Book 3\"}\n"
chunk: "{\"id\":4,\"title\":\"Book 4\"}\n"
chunk: "{\"id\":5,\"title\":\"Book 5\"}\n"
If you call res.write() directly instead, honour its return value and wait for 'drain' when it is false; the mechanics are the ones explained in
Streams. For compression of such a stream, see Compression with zlib.
Malformed requests and error handling
Not every error reaches your listener. When the parser rejects bytes (a garbage start line, a header block over maxHeaderSize, a missing Host),
the server emits 'clientError' on the http.Server and, by default, answers 400 Bad Request (or 431 Request Header Fields Too Large for
oversized headers) and closes the socket. If you attach a 'clientError' listener you take over: you must close or destroy the socket yourself. The program below
sends four raw requests to a server with a 1 KiB header limit.
import http from 'node:http';
import net from 'node:net';
const server = http.createServer({ maxHeaderSize: 1024 }, (req, res) => {
const { headers, headersDistinct, rawHeaders } = req;
res.end(JSON.stringify({ joined: headers['x-tag'], distinct: headersDistinct['x-tag'],
raw: rawHeaders.filter((_, i) => i % 2 === 0).filter((n) => /x-tag/i.test(n)) }) + '\n');
});
const raw = (port, text) => new Promise((resolve) => {
const s = net.connect(port, '127.0.0.1');
s.write(text);
let out = '';
s.setEncoding('utf8').on('data', (c) => (out += c)).on('close', () => {
const [head, body] = out.split('\r\n\r\n');
resolve(head.split('\r\n')[0] + (body?.startsWith('{') ? ` | ${body.trim()}` : '')); // status line, and the body when there is one
});
});
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const { port } = server.address();
console.log(await raw(port, 'GET / HTTP/1.1\r\nHost: x\r\nX-Tag: a\r\nx-tag: b\r\nConnection: close\r\n\r\n'));
console.log(await raw(port, 'THIS IS NOT HTTP\r\n\r\n'));
console.log(await raw(port, `GET / HTTP/1.1\r\nHost: x\r\nX-Big: ${'a'.repeat(2000)}\r\n\r\n`));
console.log(await raw(port, 'GET / HTTP/1.1\r\n\r\n'));
server.close();
});
HTTP/1.1 200 OK | {"joined":"a, b","distinct":["a","b"],"raw":["X-Tag","x-tag"]}
HTTP/1.1 400 Bad Request
HTTP/1.1 431 Request Header Fields Too Large
HTTP/1.1 400 Bad Request
For errors inside your listener, the robust pattern is a single place that turns exceptions into responses, as in the body-reading example: an
HttpError class carrying a status for the failures you expect, and a catch-all that logs the cause and answers a generic 500. Two rules keep it correct.
First, check res.headersSent in the catch block: if the head has already gone out, the only honest move is res.destroy(), because a second status
line cannot be sent. Second, never let a rejection escape an async listener: http.Server does not await the function it calls, so an unhandled
rejection becomes a process-level event (see
Errors & Error Handling).
Routing without a framework
Routing means choosing a handler from the method and the path. The simplest version compares req.url to strings, and that has two flaws:
req.url includes the query string, so /books?genre=sci-fi never equals /books, and the combinations of path and method multiply into
nested switch statements. Parsing the path with new URL(req.url, base).pathname removes the first flaw. For the second, Node.js now ships
URLPattern, the WHATWG pattern matcher (Stability 1, Experimental; global from v24.0.0), which supports named groups, wildcards and
regular-expression constraints.
import http from 'node:http';
// A tiny router: one URLPattern per route, matched on the pathname only
const routes = [];
const route = (method, pathname, handler) =>
routes.push({ method, pattern: new URLPattern({ pathname }), handler });
const books = new Map([['1', { id: '1', title: 'Dune' }], ['2', { id: '2', title: 'Emma' }]]);
const send = (res, status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
route('GET', '/books', (req, res) => send(res, 200, [...books.values()]));
route('GET', '/books/:id', (req, res, { id }) =>
books.has(id) ? send(res, 200, books.get(id)) : send(res, 404, { error: 'No such book' }));
route('GET', '/files/*', (req, res, groups) => send(res, 200, { rest: groups[0] }));
route('GET', '/authors/:id(\\d+)', (req, res, { id }) => send(res, 200, { author: Number(id) }));
function dispatch(req, res) {
const { pathname } = new URL(req.url, 'http://localhost');
const allowed = new Set();
for (const { method, pattern, handler } of routes) {
const match = pattern.exec({ pathname });
if (!match) continue;
if (method === req.method) return handler(req, res, match.pathname.groups);
allowed.add(method);
}
if (allowed.size) {
res.setHeader('Allow', [...allowed].join(', '));
return send(res, 405, { error: 'Method Not Allowed' });
}
send(res, 404, { error: 'Not Found' });
}
const server = http.createServer(dispatch);
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const base = `http://127.0.0.1:${server.address().port}`;
for (const [method, path] of [
['GET', '/books'], ['GET', '/books/2'], ['GET', '/books/99'], ['GET', '/files/a/b.txt'],
['GET', '/authors/7'], ['GET', '/authors/abc'], ['DELETE', '/books/1'], ['GET', '/nowhere'],
]) {
const r = await fetch(base + path, { method });
console.log(method, path, r.status, r.headers.get('allow') ?? '', await r.text());
}
server.close();
server.closeAllConnections();
});
GET /books 200 [{"id":"1","title":"Dune"},{"id":"2","title":"Emma"}]
GET /books/2 200 {"id":"2","title":"Emma"}
GET /books/99 404 {"error":"No such book"}
GET /files/a/b.txt 200 {"rest":"a/b.txt"}
GET /authors/7 200 {"author":7}
GET /authors/abc 404 {"error":"Not Found"}
DELETE /books/1 405 GET {"error":"Method Not Allowed"}
GET /nowhere 404 {"error":"Not Found"}
The pieces of the pattern syntax used above:
| Pattern | Matches | Result in match.pathname.groups |
|---|---|---|
|
Exactly |
Empty object |
|
One path segment after |
|
|
Anything after |
|
|
A segment that satisfies the regular expression (written with a doubled backslash in a JavaScript string) |
|
Source: Node.js API: URLPattern
Design notes for a hand-rolled router. Collect the methods of every route whose path matches, and answer 405 with an Allow header when the
method is wrong but the path is known, instead of a misleading 404. Match against pathname only, so the query string never interferes, and read
query parameters with url.searchParams. Build the pattern objects once, at start-up, as the example does, not per request. Decode and validate
every captured value, since groups are strings that come straight from the client. Because URLPattern is Experimental, wrap it behind a small
function such as route(), so that you can swap it for a framework router, a regular expression or a polyfill later without touching handlers; on
Node.js 22, where it does not exist, that wrapper is the only thing you would change. When routes, middleware and validation grow beyond a screen,
move to a framework; see Web Frameworks: Express & Fastify and
Building a REST API.
Serving static files with streams
A static file server is the best small exercise in streaming and security. Reading the file with readFile() and then calling end(data) works,
but it holds the whole file in memory for every concurrent download. Streaming sends it in chunks (64 KiB by default) with constant memory per request,
and pipeline() stops reading when the client stops listening. The hard part is not the streaming but the path handling, because the request
path is attacker-controlled input.
import http from 'node:http';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
const root = path.resolve(import.meta.dirname, 'public');
const types = new Map([
['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
['.js', 'text/javascript; charset=utf-8'],
['.json', 'application/json'],
['.svg', 'image/svg+xml'],
['.txt', 'text/plain; charset=utf-8'],
]);
function fail(res, status) {
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`${status} ${http.STATUS_CODES[status]}\n`);
}
async function serveStatic(req, res) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Allow', 'GET, HEAD');
return fail(res, 405);
}
let pathname;
try {
pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
return fail(res, 400); // malformed percent-encoding
}
if (pathname.includes('\0')) return fail(res, 400);
const file = path.join(root, pathname.endsWith('/') ? `${pathname}index.html` : pathname);
if (!file.startsWith(root + path.sep)) return fail(res, 403); // path traversal
let info;
try {
info = await stat(file);
} catch {
return fail(res, 404);
}
if (!info.isFile()) return fail(res, 404);
res.writeHead(200, {
'Content-Type': types.get(path.extname(file)) ?? 'application/octet-stream',
'Content-Length': info.size,
'Last-Modified': info.mtime.toUTCString(),
'X-Content-Type-Options': 'nosniff',
});
if (req.method === 'HEAD') return res.end();
try {
// pipeline() applies backpressure and destroys the file stream if the client goes away
await pipeline(createReadStream(file), res);
} catch (err) {
if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') console.error(err.message);
res.destroy(); // the head is already sent: all we can do is hang up
}
}
const server = http.createServer((req, res) => {
serveStatic(req, res).catch(() => res.headersSent ? res.destroy() : fail(res, 500));
});
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const base = `http://127.0.0.1:${server.address().port}`;
for (const [method, p] of [['GET', '/'], ['GET', '/css/site.css'], ['HEAD', '/big.txt'],
['GET', '/missing.html'], ['POST', '/'], ['GET', '/css']]) {
const r = await fetch(base + p, { method });
const body = await r.text();
console.log(method, p, r.status, r.headers.get('content-type'), r.headers.get('content-length'), JSON.stringify(body));
}
server.close();
server.closeAllConnections();
});
The server expects a public directory next to the script. These commands create one for the test run:
mkdir -p public/css
echo '<h1>Bookshelf</h1>' > public/index.html
echo 'body { margin: 0 }' > public/css/site.css
head -c 20000000 /dev/zero | tr '\0' 'a' > public/big.txt
node static-server.mjs
GET / 200 text/html; charset=utf-8 19 "<h1>Bookshelf</h1>\n"
GET /css/site.css 200 text/css; charset=utf-8 19 "body { margin: 0 }\n"
HEAD /big.txt 200 text/plain; charset=utf-8 20000000 ""
GET /missing.html 404 text/plain; charset=utf-8 null "404 Not Found\n"
POST / 405 text/plain; charset=utf-8 null "405 Method Not Allowed\n"
GET /css 404 text/plain; charset=utf-8 null "404 Not Found\n"
What each step of serveStatic() defends against:
| Step | Reason |
|---|---|
Accept only |
Other methods get 405 with |
|
Malformed percent-encoding throws |
|
Stops |
|
Directories and special files get 404, and the size feeds |
A content-type table plus |
The browser must not guess a type from content. Unknown extensions fall back to |
|
Backpressure from the socket pauses the file; a client that disconnects mid-download destroys the file stream and closes the descriptor.
|
|
After |
Real file servers also add caching validators (ETag, If-None-Match, If-Modified-Since with a 304), Range requests for media, compression and
directory listings. That is the point at which a maintained package or a CDN is the better choice; the principle (stream, validate the path, never trust the
extension) stays the same. See File System for stat(), createReadStream() options and file descriptors.
Keep-alive connections and the Agent
HTTP/1.1 connections are persistent by default: after a response, the TCP connection stays open so the next request skips the handshake (and, for HTTPS, the
TLS negotiation). On the server side, server.keepAliveTimeout (observed 5000 ms, plus keepAliveTimeoutBuffer, 1000 ms since v24.6.0 and v22.19.0)
decides how long an idle socket is kept, and the response advertises it with Keep-Alive: timeout=5. server.maxRequestsPerSocket (default 0, unlimited) caps
requests per connection; after the cap, the server sets Connection: close and answers later requests with 503. The knobs for hardening these are in
Server Timeouts, Limits & Hardening.
On the client side, an http.Agent manages the pool of sockets for http.request() and http.get(): it queues requests per host and port, reuses sockets
while the queue lasts and, with keepAlive: true, parks idle sockets in a pool for later. A plain new Agent() keeps keepAlive off and closes each
socket after its request, which is why it advertises Connection: close. Since v19.0.0 the default http.globalAgent has keep-alive on and a 5 second timeout. The
experiment counts TCP connections made by five sequential requests with each kind of agent.
import http from 'node:http';
import { text } from 'node:stream/consumers';
let connections = 0;
const server = http.createServer((req, res) => {
res.end('ok');
});
server.on('connection', () => connections++);
console.log('server defaults:', {
keepAliveTimeout: server.keepAliveTimeout,
keepAliveTimeoutBuffer: server.keepAliveTimeoutBuffer,
headersTimeout: server.headersTimeout,
requestTimeout: server.requestTimeout,
maxRequestsPerSocket: server.maxRequestsPerSocket,
});
// Promise wrapper around http.request that reads the body and reports the Keep-Alive header
const get = (port, agent) => new Promise((resolve, reject) => {
http.get({ port, host: '127.0.0.1', agent }, async (res) => {
await text(res);
resolve(`${res.headers.connection}/${res.headers['keep-alive'] ?? '-'}`);
}).on('error', reject);
});
async function run(label, agent, port) {
connections = 0;
const seen = [];
for (let i = 0; i < 5; i++) seen.push(await get(port, agent)); // sequential requests
console.log(label.padEnd(26), 'TCP connections:', connections, ' response says:', seen[0]);
agent.destroy();
}
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const { port } = server.address();
await run('new Agent()', new http.Agent(), port);
await run('new Agent({ keepAlive })', new http.Agent({ keepAlive: true }), port);
await run('http.globalAgent', http.globalAgent, port);
console.log('globalAgent.keepAlive:', http.globalAgent.keepAlive, ' maxSockets:', http.globalAgent.maxSockets, ' maxFreeSockets:', http.globalAgent.maxFreeSockets);
server.close();
});
server defaults: {
keepAliveTimeout: 5000,
keepAliveTimeoutBuffer: 1000,
headersTimeout: 60000,
requestTimeout: 300000,
maxRequestsPerSocket: 0
}
new Agent() TCP connections: 5 response says: close/-
new Agent({ keepAlive }) TCP connections: 1 response says: keep-alive/timeout=5
http.globalAgent TCP connections: 1 response says: keep-alive/timeout=5
globalAgent.keepAlive: true maxSockets: Infinity maxFreeSockets: 256
| Option | Default | Effect |
|---|---|---|
|
|
Keep idle sockets in a pool for reuse. Not the same thing as the |
|
|
Initial delay for TCP keep-alive probes. |
|
|
Milliseconds subtracted from the server’s |
|
|
Concurrent sockets per host; extra requests wait in a queue. |
|
|
Concurrent sockets across all hosts (v14.5.0, v12.19.0). |
|
|
Idle sockets to keep per host, when |
|
|
Which free socket is picked next: |
|
none |
Socket timeout in milliseconds, set when the socket is created. |
|
|
Proxy settings for requests through this agent; built-in proxy support is Stability 1.1 (v24.5.0, v22.21.0). |
A few consequences follow. A server that sits behind a load balancer should keep its idle timeout longer than the balancer’s, otherwise the balancer
sends a request on a socket the server is closing and the client sees ECONNRESET; that is the reason keepAliveTimeoutBuffer exists. Keep-alive also makes
a slow client expensive, since it holds a socket and a parser slot, so the request and header timeouts matter. The global fetch() is not built on
http.Agent: it uses its own pooled connections from the Undici client, so the agent settings above do not apply to it. A separate experiment on
Node.js 26, 24 and 22 (observed) made five sequential fetch() calls and the server counted two TCP connections. Clients, retries and proxies are covered in
Fetch & HTTP Clients; HTTPS and HTTP/2, which multiplexes many requests over one connection, in
HTTPS, TLS & HTTP/2.
Shutting down cleanly
Keep-alive has a consequence for shutdown: idle connections do not end by themselves. server.close() stops accepting new connections and, since v19.0.0, closes
idle ones, but a connection that was busy when close() was called is not swept afterwards: it lingers until the peer or the idle timeout ends it. In an experiment on Node.js 26, 24 and 22
(observed) in which close() was called while one slow request was in flight, the close callback ran about 3.25 seconds after the request had been answered, because the
client kept the idle connection open. Sweeping idle connections with closeIdleConnections() while waiting, and falling back to closeAllConnections() after a
grace period, brings that down to the duration of the in-flight request.
import http from 'node:http';
const server = http.createServer(async (req, res) => {
if (req.url === '/slow') await new Promise((r) => setTimeout(r, 300));
res.end('done\n');
});
// Stop accepting connections, let in-flight requests finish, then hang up on idle sockets
function shutdown(graceMs = 5000) {
return new Promise((resolve) => {
console.time('shutdown');
server.close(() => { // runs once every connection has ended
console.timeEnd('shutdown');
resolve();
});
const sweep = setInterval(() => server.closeIdleConnections(), 50);
setTimeout(() => server.closeAllConnections(), graceMs).unref(); // last resort
server.once('close', () => clearInterval(sweep));
});
}
server.listen({ port: 0, host: '127.0.0.1' }, async () => {
const base = `http://127.0.0.1:${server.address().port}`;
await (await fetch(`${base}/`)).text();
const slow = fetch(`${base}/slow`).then((r) => r.text()); // still in flight while we shut down
await new Promise((r) => setTimeout(r, 50));
await shutdown();
console.log('in-flight request answered:', JSON.stringify(await slow));
});
shutdown: 253.791ms
in-flight request answered: "done\n"
Source: Node.js API: server.close(), Node.js API: server.closeIdleConnections(), Node.js API: server.closeAllConnections()
closeAllConnections() is forceful: it also destroys connections that are receiving a request or waiting for a response, and the documentation recommends calling it after
close(). A real service calls shutdown() from a SIGTERM handler and exits with an appropriate code; see
Process, Signals & Exit Codes. Where explicit resource management is available (Node.js 24 and later),
a server can be tied to a block with await using, because server[Symbol.asyncDispose]() calls close() and returns a promise:
import http from 'node:http';
{
await using server = http.createServer((req, res) => res.end('ok'));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const r = await fetch(`http://127.0.0.1:${server.address().port}`);
console.log(r.status, await r.text());
} // leaving the block calls server[Symbol.asyncDispose](), i.e. server.close()
console.log('closed');
200 ok
closed
Source: Node.js API: server[Symbol.asyncDispose()]
On Node.js 22, the same file fails to parse (await using is a syntax error there); call server.close() in a finally block instead.
Bookshelf: a JSON API with static files
The pieces above combine into the first Bookshelf server: a JSON API for books (list with a genre filter, read, create, delete), static files for everything else, one
error path and no dependencies. It exports createApp() so a test can start it on port 0, and starts listening only when run directly (import.meta.main, available since v24.2.0
and v22.18.0). It needs URLPattern, so it runs on Node.js 26 and 24; the public directory is the one created in the static files section.
import http from 'node:http';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { randomUUID } from 'node:crypto';
export class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
const json = (res, status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(body));
};
async function readJson(req, limit = 64 * 1024) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw new HttpError(413, 'Payload Too Large');
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw new HttpError(400, 'Body is not valid JSON');
}
}
export function createApp({ publicDir = path.resolve(import.meta.dirname, 'public') } = {}) {
const books = new Map();
const routes = [];
const route = (method, pathname, handler) =>
routes.push({ method, pattern: new URLPattern({ pathname }), handler });
route('GET', '/api/books', (req, res, _params, url) => {
const genre = url.searchParams.get('genre');
const all = [...books.values()];
json(res, 200, genre ? all.filter((b) => b.genre === genre) : all);
});
route('GET', '/api/books/:id', (req, res, { id }) => {
if (!books.has(id)) throw new HttpError(404, 'No such book');
json(res, 200, books.get(id));
});
route('POST', '/api/books', async (req, res) => {
const { title, genre = 'unknown' } = await readJson(req);
if (typeof title !== 'string' || title.trim() === '') throw new HttpError(422, 'title is required');
const book = { id: randomUUID(), title: title.trim(), genre };
books.set(book.id, book);
res.setHeader('Location', `/api/books/${book.id}`);
json(res, 201, book);
});
route('DELETE', '/api/books/:id', (req, res, { id }) => {
if (!books.delete(id)) throw new HttpError(404, 'No such book');
res.writeHead(204).end();
});
async function serveStatic(req, res, pathname) {
const file = path.join(publicDir, pathname.endsWith('/') ? `${pathname}index.html` : pathname);
if (!file.startsWith(publicDir + path.sep)) throw new HttpError(403, 'Forbidden');
const info = await stat(file).catch(() => null);
if (!info?.isFile()) throw new HttpError(404, 'Not Found');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': info.size });
if (req.method === 'HEAD') return res.end();
await pipeline(createReadStream(file), res);
}
async function handle(req, res) {
const url = new URL(req.url, 'http://localhost');
const pathname = decodeURIComponent(url.pathname);
const allowed = new Set();
for (const { method, pattern, handler } of routes) {
const match = pattern.exec({ pathname });
if (!match) continue;
if (method === req.method) return handler(req, res, match.pathname.groups, url);
allowed.add(method);
}
if (allowed.size) {
res.setHeader('Allow', [...allowed].join(', '));
throw new HttpError(405, 'Method Not Allowed');
}
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res, pathname);
throw new HttpError(404, 'Not Found');
}
return http.createServer(async (req, res) => {
try {
await handle(req, res);
} catch (err) {
if (res.headersSent) return res.destroy(err); // too late for a status code
const status = err instanceof HttpError ? err.status
: err instanceof URIError ? 400 : 500;
if (status === 500) console.error(err);
json(res, status, { error: status === 500 ? 'Internal Server Error' : err.message });
}
});
}
// Run directly (node app.mjs) but not when imported by a test
if (import.meta.main) {
const server = createApp();
server.listen({ port: Number(process.env.PORT ?? 3000), host: '127.0.0.1' }, () => {
console.log('Bookshelf listening on', server.address());
});
}
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from './app.mjs';
let server;
let base;
before(async () => {
server = createApp();
await new Promise((resolve) => server.listen({ port: 0, host: '127.0.0.1' }, resolve));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
server.closeAllConnections();
});
test('create, read, filter and delete a book', async () => {
const created = await fetch(`${base}/api/books`, {
method: 'POST',
body: JSON.stringify({ title: 'Dune', genre: 'sci-fi' }),
});
assert.equal(created.status, 201);
const book = await created.json();
assert.equal(created.headers.get('location'), `/api/books/${book.id}`);
const list = await (await fetch(`${base}/api/books?genre=sci-fi`)).json();
assert.deepEqual(list, [book]);
assert.equal((await fetch(`${base}/api/books/${book.id}`, { method: 'DELETE' })).status, 204);
assert.equal((await fetch(`${base}/api/books/${book.id}`)).status, 404);
});
test('validation, methods and static files', async () => {
const bad = await fetch(`${base}/api/books`, { method: 'POST', body: '{}' });
assert.equal(bad.status, 422);
const wrong = await fetch(`${base}/api/books`, { method: 'PUT' });
assert.equal(wrong.status, 405);
assert.equal(wrong.headers.get('allow'), 'GET, POST');
assert.equal((await fetch(`${base}/api/nowhere`)).status, 404);
assert.match(await (await fetch(`${base}/`)).text(), /Bookshelf/);
assert.equal((await fetch(`${base}/%zz`)).status, 400);
});
✔ create, read, filter and delete a book
✔ validation, methods and static files
ℹ tests 2
ℹ pass 2
ℹ fail 0
Source: Node.js API: HTTP, Node.js API: URLPattern
Reading it against the earlier sections shows what a framework would otherwise supply: route() and dispatch are the router, readJson() is the body parser, the
try/catch is the error handler, and serveStatic() is the static middleware. It also shows what is still missing and would come next: input validation beyond one field, authentication,
persistence (see Databases from Node.js and SQLite), request logging, CORS, rate limiting and the timeouts from
Server Timeouts, Limits & Hardening. Testing an HTTP server in depth is the subject of
Test Runner. The same primitives appear in other pages of this guide: NestJS exposes them through its Fastify platform
(NestJS: Fastify & HTTP Platforms) and its streaming helpers
(NestJS: File Upload & Streaming).
What changed recently
| Version | Change |
|---|---|
v26.7.0 and v24.20.0 |
|
v26.3.0 and v24.19.0 |
The |
v26.2.0 and v24.18.0 |
|
v26.1.0 and v24.16.0 |
|
v26.0.0 |
|
v25.4.0 and v24.14.0 |
|
v25.1.0 and v24.12.0 |
|
v24.9.0 and v22.21.0 |
|
v24.7.0 and v22.20.0 |
|
v24.6.0 and v22.19.0 |
|
v24.5.0 and v22.21.0 |
|
v24.2.0 and v22.17.0 |
Calling the |
v24.0.0 |
|
v19.4.0 and v18.14.0 |
|
v19.0.0 |
|
v18.2.0 |
|
v18.0.0 |
|
Where the books are outdated
Efficient Node.js (Samer Buna, O’Reilly 2025) introduces node:http in its first chapter and returns to it for blocking, clustering and routing, and it is good at explaining the request
listener, the req and res roles and why a frameworkless server gets tedious. It is worth reading beside this page, with these updates:
| Topic | Current behaviour |
|---|---|
Routing without a framework |
The book routes with a |
Static files "without Express" |
The book reads the file into memory and sends it with |
Fixed port 3000 |
Hard-coded ports collide when examples or tests run in parallel; use |
Keep-alive, the Agent and server timeouts |
The book does not cover them. |
Client disconnects |
Not covered; |
Module style |
The first server example uses CommonJS |
Node.js Projects (Jonathan Wexler, O’Reilly 2025) builds its web server chapters on Fastify rather than on node:http, so the low-level layer on this page is background for it: Fastify’s request and
reply objects wrap the same IncomingMessage and ServerResponse. One of its early chapters installs fastify@^4.28.1; Fastify 4 reached end of life on 2025-06-30 according to the Fastify long-term-support
page, and Fastify 5 is the supported line (the book’s later chapters already use fastify@^5). Plugin packages must match the Fastify major. For frameworks, see
Web Frameworks: Express & Fastify.
Pitfalls and good practice
| Pitfall | What to do |
|---|---|
Forgetting to call |
The client waits until a timeout. Every code path, including errors, must end the response. |
Writing a second response after an error |
Check |
Buffering whole request bodies |
Stream with |
Trusting |
Build URLs from configuration; honour forwarding headers only from a known proxy. |
Serving user-supplied paths |
Decode first, |
An |
Wrap the body in |
A hard-coded port in tests |
Use port 0 and read |
Blocking the event loop in a handler |
One slow synchronous task stalls every client; see Don’t Block the Event Loop. |
Exposing the server directly to the internet with default timeouts |
Put a reverse proxy in front and tune |
|
Leave them off; they weaken protection against request smuggling and header injection. |
Relying on |
Use |
Source: Node.js API: Deprecations
References
-
Node.js Learn: Anatomy of an HTTP Transaction, https://nodejs.org/learn/http/anatomy-of-an-http-transaction
-
Node.js API: HTTP, https://nodejs.org/docs/latest/api/http.html
-
Node.js API: Usage and example (synopsis), https://nodejs.org/docs/latest/api/synopsis.html
-
Node.js API: http.createServer(), https://nodejs.org/docs/latest/api/http.html#httpcreateserveroptions-requestlistener
-
Node.js API: http.IncomingMessage, https://nodejs.org/docs/latest/api/http.html#class-httpincomingmessage
-
Node.js API: http.ServerResponse, https://nodejs.org/docs/latest/api/http.html#class-httpserverresponse
-
Node.js API: response.writeHead(), https://nodejs.org/docs/latest/api/http.html#responsewriteheadstatuscode-statusmessage-headers
-
Node.js API: message.signal, https://nodejs.org/docs/latest/api/http.html#messagesignal
-
Node.js API: Event 'clientError', https://nodejs.org/docs/latest/api/http.html#event-clienterror
-
Node.js API: http.Agent, https://nodejs.org/docs/latest/api/http.html#class-httpagent
-
Node.js API: http.globalAgent, https://nodejs.org/docs/latest/api/http.html#httpglobalagent
-
Node.js API: server.close(), https://nodejs.org/docs/latest/api/http.html#serverclosecallback
-
Node.js API: Built-in proxy support, https://nodejs.org/docs/latest/api/http.html#built-in-proxy-support
-
Node.js API: Net, server.listen(options), https://nodejs.org/docs/latest/api/net.html#serverlistenoptions-callback
-
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: Stream, stream.pipeline(), https://nodejs.org/docs/latest/api/stream.html#streampipelinesource-transforms-destination-options
-
Node.js API: Web Streams, Utility consumers, https://nodejs.org/docs/latest/api/webstreams.html#utility-consumers
-
Node.js API: File system, fs.createReadStream(), https://nodejs.org/docs/latest/api/fs.html#fscreatereadstreampath-options
-
Node.js API: Errors, ERR_HTTP_HEADERS_SENT, https://nodejs.org/docs/latest/api/errors.html#err_http_headers_sent
-
Node.js API: Errors, ERR_STREAM_PREMATURE_CLOSE, https://nodejs.org/docs/latest/api/errors.html#err_stream_premature_close
-
Node.js API: Deprecations, https://nodejs.org/docs/latest/api/deprecations.html
-
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/