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

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

node:http: createServer(), Server, IncomingMessage, ServerResponse, Agent

2 Stable

Server extends net.Server; IncomingMessage extends stream.Readable; ServerResponse extends OutgoingMessage, which extends the legacy Stream class.

URLPattern (global and node:url)

1 Experimental

Class added to node:url in v23.8.0, global since v24.0.0. It does not exist on Node.js 22 (typeof URLPattern is 'undefined'). On 26 and 24 it ran here without an ExperimentalWarning, which is observed behaviour and not a promise.

message.signal (req.signal)

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.

server.close() closing idle connections, closeIdleConnections(), closeAllConnections()

Stable

closeAllConnections() and closeIdleConnections() added in v18.2.0; close() closes idle connections itself since v19.0.0.

server[Symbol.asyncDispose]()

Stable

Added in v20.4.0, no longer experimental in v24.2.0; needs a runtime that parses await using (Node.js 24 and later, not 22).

http.globalAgent with keep-alive on

Stable

Keep-alive and a 5 second timeout by default since v19.0.0.

server.keepAliveTimeoutBuffer, new Agent({ agentKeepAliveTimeoutBuffer })

Stable

Server property added in v24.6.0 and v22.19.0; the Agent option in v24.7.0 and v22.20.0.

response.writeInformation()

Stable

Added in v26.2.0; the v24.x documentation lists it from v24.18.0.

httpValidation server option

Stable

Added in v26.3.0 and v24.19.0; not in the 22 documentation.

ServerResponse.prototype.writeHeader()

End-of-Life (DEP0063)

Removed in v26.0.0: use writeHead().

Built-in proxy support (proxyEnv, http.setGlobalProxyFromEnv())

1.1 Active development

Added in v24.5.0 and v22.21.0; setGlobalProxyFromEnv() in v25.4.0 and v24.14.0. See Fetch & HTTP Clients.

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).

sequenceDiagram autonumber participant C as Client participant N as Socket (libuv and net) participant P as http.Server (llhttp parser) participant H as Request listener C->>N: TCP connect N->>P: connection event with the new socket C->>N: GET /books/1 HTTP/1.1 and headers N->>P: bytes read from the socket P->>P: parse start line and headers P->>H: request event with req and res Note over H: req is a Readable stream, res accepts write and end H->>H: route, read the body, do async work H->>P: res.writeHead(200, headers) H->>P: res.write(chunk) then res.end() P->>N: status line, headers and body bytes N->>C: HTTP/1.1 200 OK and body Note over N,P: the socket stays open for the next request (keep-alive) C->>N: next request on the same connection
Figure 1. A request and its response travelling through a Node.js HTTP server

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.

The raw bytes of one transaction
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();
  });
});
Output
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.

Table 2. Where each part of the transaction lives in Node.js
Part of the message Request side (IncomingMessage) Response side (ServerResponse)

Start line

req.method, req.url, req.httpVersion

res.statusCode, res.statusMessage, res.writeHead()

Header fields

req.headers (lower-cased names), req.headersDistinct, req.rawHeaders

res.setHeader(), res.getHeaders(), res.removeHeader(), res.headersSent

Body

The request object itself is a Readable stream

res.write(), res.end(), or the response as a pipeline() destination

Trailers

req.trailers, req.rawTrailers, available after the 'end' event

res.addTrailers()

Connection

req.socket (a net.Socket)

res.socket; req.connection and res.connection are deprecated aliases (DEP0133, DEP0149)

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.

The smallest useful server
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();
});
Output (the port differs on every run)
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.

Registering the listener with server.on
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.IncomingMessage extends stream.Readable. It is created separately from the socket (which is a Duplex) because a keep-alive socket serves many messages in turn. Everything from the streams page applies: async iteration, pipeline(), 'end' and 'close', backpressure.

  • http.ServerResponse extends http.OutgoingMessage, which extends the legacy Stream class. It has the Writable interface (write(), end(), 'drain', 'finish', 'close') and works as a pipeline() destination, but it is not a stream.Writable subclass: on Node.js 26, res instanceof Writable is false (observed), so code that checks the class will be surprised.

  • The server is a net.Server, so listen(), 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.

Table 3. Selected http.createServer options (defaults from the Node.js 26 documentation)
Option Default Meaning

headersTimeout

60000 (never more than requestTimeout)

Time allowed to receive the complete headers; on expiry the server answers 408 and closes the connection.

requestTimeout

300000

Time allowed to receive the entire request; default changed from "no timeout" in v18.0.0.

keepAliveTimeout

5000 observed (see below)

Idle time a server waits for another request on a kept-alive socket.

maxHeaderSize

16384 (16 KiB)

Maximum request header size in bytes; overrides --max-http-header-size for this server. Oversized headers get a 431.

requireHostHeader

true

Answer 400 to any HTTP/1.1 request without a Host header.

joinDuplicateHeaders

false

Join duplicate request header lines with a comma instead of discarding the repeats (v19.5.0, v18.14.0).

httpValidation

'strict'

Header value validation level: 'strict', 'relaxed' (limited non-ASCII characters, aligned with the Fetch specification) or 'insecure' (v26.3.0, v24.19.0).

insecureHTTPParser

false

Lenient parser; the documentation says its use should be avoided.

noDelay

true

Disable Nagle’s algorithm on new connections; default changed to true in v18.0.0.

highWaterMark

See stream.getDefaultHighWaterMark()

Overrides the socket, request and response highWaterMark (v20.1.0, v18.17.0).

optimizeEmptyRequests

false

Give body-less requests an already-ended body stream so they never emit 'data' or 'end' (v25.1.0, v24.12.0).

IncomingMessage, ServerResponse

The built-in classes

Substitute subclasses; useful for adding helpers such as res.json() without monkey-patching.

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.

Port 0, the listening event and an AbortSignal
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
Output
{
  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.

Reading the method, the URL and the headers
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();
});
Output
{
  "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.

Table 4. How duplicate request headers are represented in req.headers
Header Behaviour

age, authorization, content-length, content-type, etag, expires, from, host, if-modified-since, if-unmodified-since, last-modified, location, max-forwards, proxy-authorization, referer, retry-after, server, user-agent

Duplicates are discarded. Set joinDuplicateHeaders: true on the server to join them with a comma instead.

set-cookie

Always an array.

cookie

Duplicates are joined with `; `.

Every other header

Duplicates are joined with `, `.

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.

Reading and validating a JSON body with a size limit
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();
});
Output
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-Length first (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 unbounded readFile-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 the 413 response 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() and text() from node:stream/consumers read 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 Buffer instances; 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.

A streaming echo server with pipeline
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();
});
Output
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.

Stopping slow work when the client disconnects (Node.js 26 and 24)
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();
});
Output
client saw: AbortError
server saw: AbortError

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 to 200), call res.setHeader() as often as you like, and the head is sent automatically with the first write() or end(). 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 with getHeader() and getHeaders(), and remove entries with removeHeader().

  • Explicit headers. res.writeHead(status[, statusMessage][, headers]) sends the head right away and returns the response, so res.writeHead(204).end() chains. Headers set earlier with setHeader() are merged, and those passed to writeHead() win. After writeHead(), res.headersSent is true.

The program below runs both styles and a failure on purpose: changing a header after the body has started throws ERR_HTTP_HEADERS_SENT.

Implicit and explicit headers, and what happens when headers are sent twice
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();
});
Output
/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"]] ""

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.

Table 5. Choosing status codes for a JSON API (reason phrases as in http.STATUS_CODES)
Code Use it when Notes for a Node.js handler

200 OK

A read or an update succeeded and you return a body.

The default; you do not need to set it.

201 Created

A resource was created.

Add Location with the new resource’s URL.

204 No Content

Success with no body, such as a DELETE.

Do not write a body; with rejectNonStandardBodyWrites: true doing so throws.

304 Not Modified

A conditional request found the cached copy valid.

Compare If-None-Match or If-Modified-Since yourself, or let a framework do it.

400 Bad Request

The message is malformed (bad JSON, bad percent-encoding).

Node.js sends this itself for unparsable HTTP.

401, 403

Not authenticated, or authenticated but not allowed.

See Authentication & Sessions.

404 Not Found

No route or resource matches.

Also the safest answer for paths that must not reveal what exists.

405 Method Not Allowed

The path exists but not for this method.

Required to carry an Allow header.

413 Payload Too Large

The body exceeded your limit.

Send Connection: close when you stop reading an unfinished upload.

422 Unprocessable Entity

The JSON parsed but fails validation.

Return the failing fields.

431

Request headers exceeded maxHeaderSize.

Sent by Node.js itself.

500, 503

Your code failed, or the service is shutting down or overloaded.

Never expose stack traces or messages in the body.

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.

Streaming newline-delimited JSON with pipeline
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();
});
Output
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.

What the server answers to malformed requests
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();
});
Output
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.

A router built from URLPattern, with 404 and 405 handling
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();
});
Output (Node.js 26 and 24; Node.js 22 has no URLPattern and throws a ReferenceError)
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:

Table 6. URLPattern pathname syntax used in the router
Pattern Matches Result in match.pathname.groups

/books

Exactly /books

Empty object

/books/:id

One path segment after /books/

{ id: '2' }

/files/*

Anything after /files/, including slashes

{ '0': 'a/b.txt' } (unnamed groups are numbered)

/authors/:id(\\d+)

A segment that satisfies the regular expression (written with a doubled backslash in a JavaScript string)

{ id: '7' }; /authors/abc does not match

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.

A static file server with path-traversal protection
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
Output
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:

Table 7. Static file handling: step by step
Step Reason

Accept only GET and HEAD

Other methods get 405 with Allow. HEAD receives the same headers and no body.

decodeURIComponent inside try

Malformed percent-encoding throws URIError; answer 400 instead of crashing. Reject a NUL byte (%00), which is never a valid file name.

path.join(root, pathname) then a prefix check against root + path.sep

Stops ..%2f..%2fetc/passwd after decoding. In a run on Node.js 26 (observed), /..%2fsecret.txt was refused with 403, while /%2e%2e/secret.txt was normalised by URL to /secret.txt and gave 404 inside the root. The check must run on the decoded path.

stat() and isFile()

Directories and special files get 404, and the size feeds Content-Length. Symbolic links that point outside the root are not caught by this prefix check; resolve with fs.realpath() first if the directory can contain links.

A content-type table plus X-Content-Type-Options: nosniff

The browser must not guess a type from content. Unknown extensions fall back to application/octet-stream.

pipeline(createReadStream(file), res)

Backpressure from the socket pauses the file; a client that disconnects mid-download destroys the file stream and closes the descriptor. ERR_STREAM_PREMATURE_CLOSE is expected in that case and not logged. A download aborted halfway through the 20 MB test file printed nothing.

res.destroy() in the catch

After writeHead() the status is committed; if reading fails halfway, hanging up is the only way to tell the client the body is incomplete.

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.

Counting TCP connections with and without keep-alive
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();
});
Output (identical on Node.js 26, 24 and 22)
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
Table 8. http.Agent options that matter (Node.js 26 documentation)
Option Default Effect

keepAlive

false for new Agent(); true on http.globalAgent

Keep idle sockets in a pool for reuse. Not the same thing as the Connection: keep-alive header, which is always sent unless keepAlive is false and maxSockets is Infinity.

keepAliveMsecs

1000

Initial delay for TCP keep-alive probes.

agentKeepAliveTimeoutBuffer

1000

Milliseconds subtracted from the server’s Keep-Alive: timeout hint, so the agent closes a socket just before the server does (v24.7.0, v22.20.0).

maxSockets

Infinity

Concurrent sockets per host; extra requests wait in a queue.

maxTotalSockets

Infinity

Concurrent sockets across all hosts (v14.5.0, v12.19.0).

maxFreeSockets

256

Idle sockets to keep per host, when keepAlive is on.

scheduling

'lifo'

Which free socket is picked next: 'lifo' (most recently used, lower risk of a socket the server has closed) or 'fifo'. Default changed from 'fifo' in v15.6.0.

timeout

none

Socket timeout in milliseconds, set when the socket is created.

proxyEnv

undefined

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.

Graceful shutdown: drain in-flight requests, then close idle sockets
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));
});
Output
shutdown: 253.791ms
in-flight request answered: "done\n"

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:

Closing a server with await using (Node.js 24 and later)
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');
Output
200 ok
closed

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.

app.mjs: the Bookshelf server
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());
  });
}
app.test.mjs: testing the server on port 0 with node:test
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);
});
Output of node --test app.test.mjs
✔ create, read, filter and delete a book
✔ validation, methods and static files
ℹ tests 2
ℹ pass 2
ℹ fail 0

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

Table 9. node:http changes by Node.js version (from the API history and the deprecations page)
Version Change

v26.7.0 and v24.20.0

message.signal is no longer aborted when the message completes normally.

v26.3.0 and v24.19.0

The httpValidation server option ('strict', 'relaxed', 'insecure').

v26.2.0 and v24.18.0

response.writeInformation() sends any 1xx response except 101.

v26.1.0 and v24.16.0

message.signal (req.signal) added.

v26.0.0

ServerResponse.prototype.writeHeader() removed (DEP0063, End-of-Life; runtime deprecation since v25.0.0). The 'upgrade' event no longer exposes the raw request body on the socket.

v25.4.0 and v24.14.0

http.setGlobalProxyFromEnv() added (built-in proxy support, Stability 1.1).

v25.1.0 and v24.12.0

optimizeEmptyRequests server option.

v24.9.0 and v22.21.0

shouldUpgradeCallback server option decides which upgrade requests fire 'upgrade'.

v24.7.0 and v22.20.0

agentKeepAliveTimeoutBuffer Agent option.

v24.6.0 and v22.19.0

server.keepAliveTimeoutBuffer.

v24.5.0 and v22.21.0

Agent options proxyEnv, defaultPort and protocol.

v24.2.0 and v22.17.0

Calling the node:http classes (IncomingMessage, ServerResponse, OutgoingMessage, ClientRequest) without new is deprecated in documentation (DEP0195); a codemod exists. server[Symbol.asyncDispose]() is no longer experimental (v24.2.0).

v24.0.0

URLPattern available as a global (class in node:url since v23.8.0).

v19.4.0 and v18.14.0

headersTimeout defaults to the lower of 60 seconds and requestTimeout.

v19.0.0

http.globalAgent uses keep-alive with a 5 second timeout; server.close() closes idle connections.

v18.2.0

server.closeAllConnections() and server.closeIdleConnections().

v18.0.0

requestTimeout defaults to 300 seconds; noDelay defaults to true; the headersTimeout, requestTimeout, keepAliveTimeout and connectionsCheckingInterval options become available.

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:

Table 10. Points to check when following Efficient Node.js
Topic Current behaviour

Routing without a framework

The book routes with a switch on req.url and moves to Express for anything bigger. req.url includes the query string, so such a switch misses /about?ref=x; parse the pathname first. URLPattern (Experimental, global since v24.0.0) now gives built-in pattern matching, as in the router above.

Static files "without Express"

The book reads the file into memory and sends it with end(), answering 500 for every read error. Stream it with pipeline(), validate the path, and map a missing file to 404, as in the static file server above.

Fixed port 3000

Hard-coded ports collide when examples or tests run in parallel; use listen({ port: 0 }) and read server.address().port.

Keep-alive, the Agent and server timeouts

The book does not cover them. http.globalAgent has had keep-alive on since v19.0.0, servers have a 5 second keep-alive timeout, a 60 second header timeout and a 5 minute request timeout, and graceful shutdown needs closeIdleConnections() and closeAllConnections().

Client disconnects

Not covered; req.signal (v26.1.0, v24.16.0) and 'close' with res.writableFinished are the current tools.

Module style

The first server example uses CommonJS require, later ones use ES modules; this page and the book’s own recommendation use import …​ from 'node:http'.

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

Table 11. Common HTTP server mistakes and their remedies
Pitfall What to do

Forgetting to call res.end()

The client waits until a timeout. Every code path, including errors, must end the response.

Writing a second response after an error

Check res.headersSent; if true, call res.destroy() and stop. ERR_HTTP_HEADERS_SENT means a head was already committed.

Buffering whole request bodies

Stream with pipeline(), or count bytes and refuse past a limit (413).

Trusting req.headers.host or x-forwarded-* blindly

Build URLs from configuration; honour forwarding headers only from a known proxy.

Serving user-supplied paths

Decode first, path.join onto a root, verify the prefix, stat() and stream; do not rely on URL normalisation alone.

An async listener whose rejection is unhandled

Wrap the body in try/catch (as above) and answer 500; http.Server ignores the returned promise.

A hard-coded port in tests

Use port 0 and read address().port.

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 headersTimeout, requestTimeout and keepAliveTimeout; see Server Timeouts, Limits & Hardening.

insecureHTTPParser or httpValidation: 'insecure'

Leave them off; they weaken protection against request smuggling and header injection.

Relying on req.connection, res.finished, req.aborted or writeHeader()

Use req.socket, res.writableEnded or res.writableFinished, 'close' with destroyed, and writeHead() (DEP0133, DEP0136, DEP0149, DEP0156, DEP0063).

References