http.Server Request/Response Lifecycle
When you type http.createServer(), it builds the JavaScript side of an HTTP server before any socket starts accepting work.
import http from 'node:http';
const server = http.createServer((req, res) => {
res.end('ok\n');
});
server.listen(3000, '127.0.0.1');The node:http module is Node's built-in HTTP/1.1 implementation for servers and clients. On the server side, it receives connected byte streams from node:net, reads HTTP messages from those bytes, and gives your code one request object and one response object for each exchange.
http.createServer() builds an http.Server. You can pass options, a request listener, or both. The request listener is the (req, res) => {} function in the example above. Node automatically registers that function on the server's request event.
The server object exists immediately. The listening endpoint comes later.
Before .listen(), the server only has JavaScript-side state - EventEmitter listeners, HTTP options, timeout values, parser settings, and the connection handler it gets from net.Server. After .listen(), the same object is also connected to lower socket state through the net.Server path covered in Chapter 9. At that point, the OS can accept TCP connections for the bound address and port. Node can wrap each accepted connection, attach HTTP parser state, and begin turning bytes into request events.
Keeping those layers straight makes debugging much easier. A bind failure belongs to server startup. A malformed request line belongs to the HTTP parser. A thrown exception in your handler belongs to application code. The same server object appears in all of those stories, but the failure starts in a separate layer each time.
The Server Object
http.Server extends net.Server. That is why an HTTP server has methods such as server.listen(), server.close(), server.address(), and the server.listening property. It is also why the lower connection event exists. HTTP adds request parsing and response writing on top of the accepted socket machinery from Chapter 9.
The flow looks like this -
http.Server
-> net.Server behavior
-> accepted net.Socket
-> HTTP parser
-> IncomingMessage + ServerResponse
-> request eventhttp.createServer(handler) is mostly a convenience call. It creates the server object and registers handler on the request event.
You can write the same thing manually -
const server = http.createServer();
server.on('request', (req, res) => {
res.end('same path\n');
});Both versions reach the same event. The named event becomes useful when you add checkContinue, clientError, dropRequest, instrumentation, or tests that attach listeners directly.
An HTTP server has two groups of events.
The lower group comes from the accepted socket path - connection, close, timeout handling, and server.close().
The HTTP group comes from parsed protocol events - request, checkContinue, clientError, dropRequest, connect, and upgrade.
Ordinary handlers live on request. Tunneling and upgrade paths belong to later chapters and later subchapters, so we keep them at the edge here.
Server options are stored before any connection exists. Some options change socket behavior, such as noDelay, keepAlive, and highWaterMark. Other options change HTTP behavior, such as requestTimeout, headersTimeout, IncomingMessage, ServerResponse, rejectNonStandardBodyWrites, and optimizeEmptyRequests.
Most application code passes a request listener and uses the defaults. Advanced code sometimes supplies custom IncomingMessage or ServerResponse classes. That works because Node creates those objects per request. The server stores your constructor choices up front, then uses them later when a parsed request needs actual request and response objects.
Those options belong to the server object. You can create two HTTP servers in the same process, give them separate headersTimeout values, and bind them to separate ports. Each accepted socket points back to the server that accepted it, so parser behavior and timeout behavior come from that specific server.
The constructor also wires one internal connection listener. That listener connects net.Server to HTTP. User code can still listen for connection, but ordinary HTTP code rarely needs it. The internal listener receives the accepted net.Socket and prepares it for HTTP parsing.
That bridge is why an HTTP server can expose both low-level and protocol-level events. A connection event means a socket reached the server. A request event means bytes on that socket formed a valid HTTP request head. Those are separate moments. Logging both can help during debugging. A connection spike with a flat request count usually points at clients that connect and stall before sending complete headers.
The request listener runs once per parsed request. One TCP connection can produce several request events when HTTP/1.1 persistence is active. Subchapter 5 covers keep-alive in detail. For now, hold onto this narrower fact - the connected socket and the request object have separate lifetimes. One socket can carry more than one IncomingMessage.
From Accepted Socket To request
HTTP starts after TCP has already done its part.
Chapter 9 covered the inbound accept path. The kernel finishes the TCP handshake, places the connected socket where user space can accept it, libuv reports readiness, and Node wraps the accepted endpoint as a net.Socket.
The HTTP server takes over from there. Its internal connection listener receives the socket, stores server state on it, attaches timeout handling, allocates a parser, and connects parser callbacks to the socket's readable bytes.
In Node v24, the parser is llhttp. Subchapter 3 covers the parser state machine, callback table, leniency flags, and parse-error codes. Here, the useful idea is simpler. The parser consumes bytes from the accepted socket and reports HTTP progress back into Node's JavaScript HTTP layer.
A normal request travels through this sequence -
accepted net.Socket
-> parser attached to socket
-> request headers parsed
-> IncomingMessage created
-> ServerResponse created
-> server emits request
-> response writes bytes to socket
-> exchange completesThe request listener fires after Node has a valid HTTP request head. That means Node has parsed the method, request target text, HTTP version, and headers. The body may still be arriving.
That detail matters for uploads and slow clients. Your handler can start running while body bytes are still on the way.
Node creates an http.IncomingMessage for the inbound message and an http.ServerResponse for the outbound message. IncomingMessage represents the request from the server's point of view. ServerResponse represents the response your code is building for that request.
Those two objects belong to one HTTP exchange.
The socket sits below them and can live longer. The parser belongs to the socket while HTTP owns that connection. The request and response belong to one parsed exchange. After the response finishes and Node has settled the request body state, Node can detach the response from the socket and either prepare for another request or close the connection.
This object layout explains a common logging surprise -
http.createServer((req, res) => {
console.log(req.socket.remoteAddress);
console.log(req.url);
res.end('ok\n');
});req.socket is the connected net.Socket. It knows the remote address, remote port, timeout behavior, and lower stream state.
req.url is the raw request target text from the HTTP request line. For an origin-form request, it looks like /users?id=12. For other request target forms covered in Subchapter 1, the text can use another structure. Node preserves the request target string and leaves routing policy to your code or framework.
When you want parsed path and query data, build a URL with a trusted base -
http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
res.end(url.pathname + '\n');
});The base supplies the scheme and host required by the URL constructor. In production code, do not blindly trust the raw Host header as that base. It comes from the client. Chapter 12 covers routing and validation policy. Here, the point is only the lifecycle - IncomingMessage gives you raw request target text, and parsing it is an application decision.
The request event uses synchronous EventEmitter dispatch. Node emits it with (req, res), and your listener runs on that call stack. If your listener starts async work, the listener returns before that async work finishes. The response object stays open until your code writes and ends it, the socket fails, or a timeout/error path tears it down.
That timing gives one handler two flows -
http.createServer(async (req, res) => {
const body = await readBody(req);
res.end(body.length + '\n');
});The handler returns a Promise because it is an async function, but http.Server still runs through events and streams. The server emits request, your handler starts, and stream work continues later. Your code must catch failures and either complete the response or destroy it. EventEmitter capture rejection behavior can help when enabled, but the normal lifecycle is still request event, stream reads, response writes, and socket completion.
Node keeps enough state on the socket to keep these pieces organized. The socket has parser state, incoming request state, outgoing response state, and backpressure signals. A response write can queue while a request body is still being read on the same connection. HTTP/1.1 sends responses in request order on a single connection, so Node tracks outgoing messages attached to that socket.
The internal property names can change across Node releases. The model stays the same - socket state, parser state, current incoming message, queued outgoing message, and stream pressure meet in the HTTP server layer.
When the parser reports a new request, Node needs three things at once. It needs a request object for incoming data, a response object for outgoing data, and a link between those two objects. The request stores its socket and parsed request-head fields. The response stores a reference to the request and later writes through the same socket. Then the server emits request with both objects.
Your listener starts while Node is still inside HTTP handling. EventEmitter dispatch is synchronous. If your listener writes a response immediately, those writes happen before the original dispatch returns. If your listener attaches body readers and returns, Node keeps the request stream alive and continues feeding body bytes as the socket produces them.
A typical request body timeline looks like this -
request head ready
-> emit request
-> handler attaches body work
-> body chunks continue later
-> response finishes laterThe request head and request body do not have the same timing. Headers must exist before request fires. Body bytes can be buffered, pending, complete, absent, or cut off. A handler that treats request as "the whole request has arrived" will fail with uploads. The event means "Node has a valid request object and response object."
Node also has to protect the connection from response queue growth. If code writes a large response while the socket is slow, the response object tracks writable pressure. If another parsed request is already waiting on the same connection, Node still has to preserve response order. HTTP/1.1 response order follows request order on a single connection, so Node's socket-level HTTP state tracks outgoing messages and drains them in order.
Request bodies add another pressure point. If a handler stops reading the request body, the readable side of IncomingMessage can fill. That pressure can pause reads from the underlying socket. Pausing reads protects memory, but it also means any later requests already sent on that connection sit behind unread body bytes. The parser must know where the current message ends before the connection can cleanly move to the next request.
The parser receives bytes through the socket. When enough bytes exist to complete the request head, Node constructs the request object and response object. Body bytes flow into the readable side of IncomingMessage. Response bytes flow through ServerResponse, then through the socket's writable side, then through libuv and the kernel send buffer. If the response write path backs up, the return value from res.write() and the drain event carry the same stream backpressure idea from Chapter 3, now attached to an HTTP message.
The body path is incremental. Node may parse the head from one packet and receive body bytes across many later reads. It may also receive the head and some body bytes in the same lower read. The public API keeps that low-level detail away from your handler. You see a request object first, then a readable stream that yields body chunks as Node makes them available.
The response path has its own incremental state. ServerResponse can accept headers, status, and body chunks before every byte reaches the kernel. A call to res.end() finishes the outgoing HTTP message from JavaScript's point of view. The socket write may still complete later, and the peer may still reset before reading it. That is why response finalization and connection finalization have separate events.
The same socket can also fail before Node has a request object. A parse error in the method token or headers belongs to clientError, because the parser stopped before a valid IncomingMessage existed. A TCP reset can arrive while the request body is still flowing. A timeout can fire while the headers are incomplete. Each of those outcomes enters Node at the socket and parser layer, then surfaces through the HTTP server events covered later in this chapter.
Handler errors need the same care. A synchronous throw from the request listener happens during EventEmitter dispatch. A rejection from async work happens later, after the listener returned. Node's HTTP server can participate in EventEmitter capture rejection behavior, but application code should still finish the response or destroy it from its own error path.
http.createServer(async (req, res) => {
try {
res.end(await render(req));
} catch (err) {
fail(res, err);
}
});The fail() helper from the header section can choose a 500 response before headers are sent, or destroy the response after the response has already started. That branch belongs in server code because your application owns its error contract.
IncomingMessage Is The Request
http.IncomingMessage is the object Node gives you as req in a server handler. It extends Readable. The readable side carries the request body. Its fields carry the parsed request head.
http.createServer((req, res) => {
console.log(req.method);
console.log(req.url);
console.log(req.headers.host);
res.end('ok\n');
});req.method is the method string. req.url is the request target string. req.headers is a lowercased header object computed from the parsed header section. These values come from the request head, so they are available as soon as the request event fires.
message.headers is convenient, but it does not preserve every detail exactly as received. Header names are lowercased. Some duplicate fields are joined. Some duplicates are discarded according to Node's header handling rules, unless you opt into duplicate joining behavior. set-cookie remains an array because that header keeps separate field values. For ordinary routing and metadata checks, req.headers is usually what you want.
message.rawHeaders keeps the received header names and values in one flat array -
http.createServer((req, res) => {
for (let i = 0; i < req.rawHeaders.length; i += 2) {
console.log(req.rawHeaders[i], req.rawHeaders[i + 1]);
}
res.end('ok\n');
});Even indexes are names. Odd indexes are values. Names keep their received case. Duplicate fields stay separate. Use rawHeaders when you are debugging what a client sent, preserving order for logs, or writing code that needs the received spelling. Most request handling should use headers, because lowercased keys avoid accidental branch mistakes.
message.trailers holds trailer fields after the body ends. The object is empty during the initial request handler because trailer fields arrive after chunked body data. The request stream needs to reach end before trailer values are useful.
The request body is the readable side of IncomingMessage. For a small POST body, code can consume it directly -
async function readBody(req) {
let body = '';
for await (const chunk of req) {
body += chunk;
}
return body;
}The for await loop reads body chunks until the request stream ends. String concatenation is fine for tiny examples. Real body parsers enforce byte limits, choose an encoding, handle parse errors, and stop early when the request is too large. Subchapter 4 covers body parsing as part of raw routing and middleware.
The request object is a stream, but the parser created it from HTTP structure. The stream ends at the end of one HTTP message body. The socket may stay open afterward. A later request on the same connection gets a new IncomingMessage.
That keeps request code easier to reason about. You can consume req until it ends and treat that as one body. Node's parser owns the byte-level message end and creates the next request object when the next request head is ready.
Backpressure still applies. If a body reader is slow, data can sit in the readable stream buffer and lower socket buffers. If the handler leaves the body unread, memory and connection reuse can suffer. A server that returns an early error for a request with a body should make a deliberate choice - drain a limited amount, destroy the request/socket, or rely on Node's connection-close path. Leaving the request body unread makes diagnostics messy because the response may finish while the socket still has inbound bytes waiting.
Tiny handlers often skip body consumption for methods that usually carry empty bodies -
http.createServer((req, res) => {
if (req.method === 'GET') {
res.end('read-only\n');
return;
}
req.resume();
res.end('ignored\n');
});req.resume() discards the body by putting the readable stream into flowing mode. The snippet is intentionally blunt. It consumes whatever body arrives and keeps the connection from being blocked behind unread data. Real handlers still need limits. Draining an unlimited request body can waste bandwidth and memory under hostile input.
A limited discard keeps that decision explicit -
async function discard(req, max) {
let seen = 0;
for await (const chunk of req) {
seen += chunk.length;
if (seen > max) req.destroy();
}
}The function consumes chunks until the body ends or the byte cap is exceeded. Destroying the request destroys the associated socket. That is a connection-level decision, and it is often cleaner than letting an oversized body continue while the response path acts like the exchange is healthy.
Request completion has two useful signals. The close event fires when the request has completed at the IncomingMessage level in current Node. message.complete reports whether Node received and parsed a full HTTP message.
req.on('close', () => {
if (req.complete) console.log('full request received');
else console.log('client cut off the request');
});That branch helps during upload debugging. A close event alone tells you the request object reached its end state. req.complete tells you whether the peer sent the full message before the connection ended.
Node v24.12.0 and newer also have optimizeEmptyRequests. When set on http.createServer(), requests with neither Content-Length nor Transfer-Encoding are initialized with an already-ended request body stream.
const server = http.createServer({
optimizeEmptyRequests: true,
}, (req, res) => {
console.log(req.readableEnded);
res.end('ok\n');
});The option removes stream events for bodyless requests in that specific case. A handler waiting for data or end on an empty request body can observe other timing with the option enabled. req.readableEnded is the API-level check for that path.
Empty request bodies still need request handling. The optimization only changes the readable stream state Node gives you when the headers already prove there is no body. The request head, response object, and server request event stay the same.
The option is useful for high-volume APIs where many requests carry headers only. It avoids an empty sequence of body events. Code that uses for await (const chunk of req) remains fine because an already-ended readable just finishes the loop. Code that attaches an end listener after the request is already ended should check stream state rather than assuming a future event.
The request fields stay available either way -
http.createServer({ optimizeEmptyRequests: true }, (req, res) => {
console.log(req.method, req.url, req.readableEnded);
res.end('ok\n');
});That line reports whether the request body side has already ended. Response state, socket state, and future keep-alive reuse remain separate.
IncomingMessage also keeps a socket reference. Use it when you need connection diagnostics -
http.createServer((req, res) => {
const { remoteAddress, remotePort } = req.socket;
res.end(`${remoteAddress}:${remotePort}\n`);
});That field points at the connection, so treat it as connection metadata. Avoid mixing raw socket reads with HTTP request reads in normal server code. The HTTP parser owns socket data while the connection is in HTTP mode, and the request body already reaches you through the IncomingMessage readable stream.
ServerResponse Is The Response Writer
http.ServerResponse is the object Node gives you as res. It extends http.OutgoingMessage, the shared base for outgoing HTTP messages. In server code, OutgoingMessage stores headers, status state, body chunks, corking state, writable flags, and the final serialized HTTP bytes.
Most handlers only touch ServerResponse directly -
http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.write('hello ');
res.end('there\n');
});response.statusCode controls the status code used for implicit headers. The default is 200. response.statusMessage controls the reason phrase when you set it. If you leave it unset, Node uses the standard phrase for the status code.
response.setHeader(name, value) stores a header for the later response head. The value can be a string, number, or string array. Node validates the header name and value. Number values get converted for network output later. setHeader() replaces a queued value for the same header name.
response.write(chunk) writes body data. The first body write also commits any pending response headers. The return value is the writable backpressure signal. true means the data made it through the local writable path. false means data is queued in user memory and drain will fire later.
The write callback is local. It runs when the chunk has flushed through Node's outgoing stream machinery to the underlying system. It does not prove that the client received the bytes.
res.write('chunk\n', err => {
if (err) console.error(err.code);
});Errors here usually mean the local write path failed or the socket became unusable. Remote receipt is outside this API point. TCP and buffering decide that below the HTTP layer.
response.end([data]) finishes the response. Every request needs a matching response end, unless the socket is destroyed or another protocol path takes over. Passing data to end() is equivalent to one final body write followed by finalization.
The response has events too. finish means Node handed the final response bytes to the underlying system for transmission. Client receipt is still a network-level fact. close means the response completed or the underlying connection ended early. When those outcomes do not line up, finish and close help you place the failure.
OutgoingMessage is worth naming because many response methods come from it. Header storage, headersSent, flushHeaders(), write(), end(), writableEnded, and writableFinished all belong to the outgoing-message machinery. ServerResponse adds server-specific behavior such as the associated request, default status code, automatic date header behavior, and server-side bodyless decisions for HEAD, 204, 304, and 1xx responses.
The response starts as metadata plus an empty body. Before commit, statusCode, statusMessage, and queued headers are JavaScript state. After commit, Node has serialized the status line and header section. Body writes then append bytes to the outgoing message.
You can build a response through implicit headers -
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('missing\n');This style is readable when response metadata is built over several branches. end() commits the pending headers at that point.
You can also send the response head explicitly -
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('missing\n');This style is compact when the status and headers are known together. Both styles produce a status line, header section, blank line, and optional body bytes on the connection.
Byte counts are important when you set Content-Length. The header value is a byte count. JavaScript string length counts code units. Use Buffer.byteLength() because UTF-8 characters can occupy more than one byte.
const body = 'hé\n';
res.setHeader('Content-Length', Buffer.byteLength(body));
res.end(body);That detail belongs in response construction because Node writes bytes to the socket. A wrong length can break reuse by making the peer misread where the message ends.
Here is the same idea with writeHead() -
http.createServer((req, res) => {
const body = 'created\n';
res.writeHead(201, {
'Content-Length': Buffer.byteLength(body),
'Content-Type': 'text/plain',
});
res.end(body);
});response.writeHead() builds and sends the response head. It also sets the status code and can set the reason phrase. Headers passed to writeHead() take precedence over values queued with setHeader().
Use setHeader() when you want to build response metadata over time and inspect it before commit. Use writeHead() when the status and headers are final at one point in the code. Mixing them is legal, but the precedence rule should be intentional.
Bodyless responses have a guard in Node v24. If you create the server with rejectNonStandardBodyWrites: true, Node throws a synchronous ERR_HTTP_BODY_NOT_ALLOWED when code writes a body for a HEAD request or for statuses such as 204 and 304.
const server = http.createServer({
rejectNonStandardBodyWrites: true,
}, (req, res) => {
res.writeHead(204);
res.end();
});The option turns a protocol mistake into an immediate JavaScript error at the write site. The default keeps older behavior and discards or avoids body bytes according to the response path. Codebases that want stricter tests often enable the option in development first.
The guard is especially useful for helper functions that always write a JSON body. A generic send(res, status, value) helper can accidentally send bytes for 204, or for a HEAD request that reused a GET handler. With the option enabled, that mistake fails near the helper call. Without the option, the server may appear fine while tests miss that the handler built a response body the protocol should not send.
HEAD is easy to misread. The server sends the same response metadata a GET would send, but the final response has no body. Your code still needs to call end(). It should end the message with an empty body.
http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain');
if (req.method === 'HEAD') return res.end();
res.end('visible body\n');
});The response lifecycle still completes. The body byte count is zero.
The response also has a socket reference while it is active -
http.createServer((req, res) => {
console.log(res.socket.remoteAddress);
res.end('ok\n');
});After response.end(), Node may set response.socket to null. That is part of response detachment. Store connection data before finalization if logs need it.
Header Commit Points
Headers are editable only until Node commits the response head.
These calls can commit headers -
response.writeHead(...)
response.flushHeaders()
response.write(...) or response.end(...)response.flushHeaders() sends the queued response head immediately. It is useful when you want the client to receive status and headers before the body is ready. After that call, body bytes can still come later, but header mutation is over.
response.headersSent reports whether the response head has been committed -
http.createServer((req, res) => {
res.write('partial\n');
console.log(res.headersSent);
res.setHeader('X-Late', '1');
res.end();
});The bug is the late setHeader(). res.write() caused Node to generate and send the response head. headersSent is already true. The later header mutation throws because Node has already serialized the metadata.
Move all response metadata before the first body write -
http.createServer((req, res) => {
res.statusCode = 202;
res.setHeader('Content-Type', 'text/plain');
res.write('accepted\n');
res.end();
});Header commit timing creates annoying bugs when async branches race against the first write. One branch starts streaming body data. Another branch tries to add a cookie, cache header, or trace header later. The first body byte wins. After that, headersSent tells you the response head has already gone out.
flushHeaders() makes the commit deliberate -
http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain');
res.flushHeaders();
setTimeout(() => res.end('later\n'), 100);
});Here, the early flush is intentional. The client can observe the status and headers before the body arrives. That can be useful for streaming responses. It also means all later metadata changes need to happen somewhere else, usually before the flush.
Internally, ServerResponse stores outgoing headers in JavaScript state until commit. At commit time, Node serializes the status line and header section into bytes, joins them with the first body chunk when possible, and writes the result through the socket. Once bytes enter the socket write path, JavaScript header fields are a record of what went out, not an editable draft.
Commit can also happen through end() with data -
http.createServer((req, res) => {
res.setHeader('X-Trace', 'abc');
res.end('done\n');
});end('done\n') commits headers and writes the final body bytes. The response is short, so the whole message may be serialized and queued as one write. The API still treats header commit as a real point of no return.
writeHead() has one behavior that surprises people. If you call setHeader() first, then pass the same header to writeHead(), the value in writeHead() wins.
res.setHeader('Content-Type', 'text/html');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok\n');The client receives text/plain. This precedence rule can be useful when a lower helper sets defaults and the final branch chooses the exact status and content type. It becomes painful when two helpers disagree silently. Keep header ownership local when possible.
That is why headersSent is the right branch point for defensive error handlers -
function fail(res, err) {
if (res.headersSent) return res.destroy(err);
res.statusCode = 500;
res.end('internal error\n');
}Before commit, the error handler can still choose status and body. After commit, the handler can only stop or finish the stream under the already-sent response head.
Header validation happens before commit too. Invalid field names or values throw. That error belongs to your response construction. If a value came from user data, validate or encode it before it reaches setHeader(). Node protects the wire format. Your code owns the response contract.
A clean response path usually has one owner for metadata. A route handler chooses status. A serializer chooses content type and body. A final writer calls end(). When those jobs are scattered across callbacks, the first body write can freeze headers earlier than later code expects. headersSent helps you recover, but the cleaner fix is to decide headers before the stream starts.
Expect: 100-continue
Expect: 100-continue gives the server a chance to approve the body before the client sends it.
The client sends headers with Expect: 100-continue, then waits. The server can send 100 Continue to accept the body, or it can send a final response and avoid reading the body.
Node exposes that decision through checkContinue -
const server = http.createServer();
server.on('checkContinue', (req, res) => {
if (req.headers.authorization === 'Bearer ok') {
res.writeContinue();
return handleUpload(req, res);
}
res.writeHead(401).end();
});The checkContinue event receives the same object types as request - an IncomingMessage and a ServerResponse. When your code handles checkContinue, that exchange enters your server through the checkContinue handler instead of the ordinary request event.
100 Continue is an informational response. It is a response head sent before the final response. res.writeContinue() sends it. After that, the client can send the body, and your code can read req as the request body stream.
With no checkContinue listeners, Node automatically sends 100 Continue when appropriate. That default keeps simple servers from hanging clients that use the expectation mechanism. Advanced servers attach checkContinue when they want to reject based on headers before accepting body bytes.
The usual reasons are size policy, auth policy, or content type policy. Keep the decision based on request-head data. Reading the body before sending 100 Continue defeats the purpose of the handshake.
A final response before writeContinue() affects connection reuse. In Node's implementation, sending a final status while the client still expects permission to send a body can make the connection a poor candidate for reuse, because the client may still put body bytes on that connection. Node handles that conservatively. For application code, the practical rule is simple - after rejecting a continued request, end the response cleanly and assume the connection may close.
This decision happens before body bytes flow, so the handler should return quickly. Expensive checks in checkContinue make the client wait. If the server needs a database lookup before accepting a large upload, put a deadline around that lookup and send a final response when the deadline expires. The protocol lets you avoid body transfer, but it also creates a point where clients can wait on your server.
The ordinary request listener still handles clients that send bodies without Expect. Code that needs shared upload logic can call a common function from both paths -
function handleUpload(req, res) {
readBody(req).then(body => {
res.end(String(body.length));
}, err => res.destroy(err));
}The function receives the same object types in both cases. Only the pre-body permission step changes.
Bad Requests Before req
Some failures happen before Node can build IncomingMessage.
A malformed method, invalid header section, header overflow, early socket error, or reset during parse can all arrive while the parser is still working on bytes. Node creates a valid req and res pair only after parser success. Earlier parser failures surface through clientError.
const server = http.createServer((req, res) => {
res.end('ok\n');
});
server.on('clientError', (err, socket) => {
if (socket.writable) {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
}
});The clientError listener receives an error and the socket. If you attach the listener, your code owns the close path for that error. Write a valid raw HTTP response if the socket is still writable, then close or destroy it. This path gives you raw socket access instead of a ServerResponse.
Node's default behavior handles common cases by sending 400 Bad Request, or 431 Request Header Fields Too Large for header overflow, then closing when possible. Custom listeners are useful when you need logging, metrics, or a specific minimal response. Keep the response tiny. The parser has already rejected the input.
err.bytesParsed and err.rawPacket can appear on parser errors. They are diagnostic fields. Logging the raw packet can help identify a broken client or bad proxy, but it may contain user data. Treat it as request data.
The socket in clientError is still a net.Socket. You can inspect remoteAddress, remotePort, and writable state. The socket may already be half-closed or reset. Defensive handlers branch on socket.writable and keep the response short. Some teams destroy immediately after logging -
server.on('clientError', (err, socket) => {
console.warn(err.code, socket.remoteAddress);
socket.destroy();
});Destroying is fine when you prefer a hard close for malformed input. Sending a tiny 400 can help when debugging manual clients. Either way, the request listener is not involved because the parser stopped before producing a request object.
dropRequest is a separate event. It happens after Node has a request object, but the server chooses to drop a new request because the socket has reached server.maxRequestsPerSocket.
server.maxRequestsPerSocket = 1000;
server.on('dropRequest', (req, socket) => {
console.warn('dropped', req.url);
});Node sends 503 Service Unavailable for the dropped request. Subchapter 5 covers the keep-alive and max-requests behavior around that threshold. Here, the event is useful because the request made it through HTTP parsing, then entered the drop path.
Parser errors also explain where framework code begins. Middleware runs after request. A parse error before request belongs to clientError. A dropped request after a per-socket limit belongs to dropRequest. A thrown exception inside middleware belongs to the framework or your handler. These are separate failure surfaces.
This is important for metrics. A dashboard that counts only handler-level 4xx responses misses malformed requests rejected before request. Add counters on clientError when you need visibility into bad clients, scanners, or proxies that send invalid HTTP. Add separate counters on dropRequest when you set maxRequestsPerSocket, because those drops mean a reusable connection crossed a server-defined request limit.
Status codes also tell you where the request died. A handler can choose any application response. clientError usually writes raw 400 or closes. Header overflow gets 431 by default. dropRequest sends 503.
clientError can also receive socket problems during HTTP parsing, so avoid treating every event as hostile input. A mobile client can drop a connection mid-header. A load balancer can close an idle connection at the same time the client starts a new request. The event means the socket left the normal request path while Node was working on HTTP input. The error code and bytes parsed give the next clue.
Request And Header Timeouts
An HTTP server has protocol timeouts above the raw socket timeout.
server.headersTimeout limits how long the parser waits for complete headers. In Node v24, the default is the smaller value of 60 seconds or server.requestTimeout.
server.requestTimeout limits how long the server waits to receive the entire request from the client. In Node v24, the default is 300,000 milliseconds, or five minutes.
const server = http.createServer({
headersTimeout: 15_000,
requestTimeout: 60_000,
}, (req, res) => {
res.end('ok\n');
});A headersTimeout expiry happens while Node is still waiting for a complete request head. Node sends 408 Request Timeout, closes the connection, and the request listener never runs for that exchange.
requestTimeout covers the full message. A slow body can stall after the head has been parsed, with the handler already running. When Node can still write the timeout response, it sends 408 Request Timeout and closes the connection.
The two protocol timeouts cover separate phases -
headersTimeout
-> request line and header section must complete
requestTimeout
-> full request, including body, must completeThe raw socket timeout is separate. server.setTimeout() and server.timeout set inactivity behavior on sockets. A socket timeout emits a timeout event and may require explicit handling if you added listeners. HTTP keep-alive timeout is another setting for idle time after a response while waiting for another request. Subchapter 5 covers that path.
Keep the names literal in code reviews. Header timeout means incomplete headers. Request timeout means incomplete full request. Socket timeout means inactivity on the connection. Keep-alive timeout means an idle reusable connection after a response. Separate names. Separate states. Separate logs.
Node also validates one relationship. When both protocol timeouts are active, headersTimeout must fit inside requestTimeout. That keeps the header deadline from being longer than the whole-message deadline.
There is also a checker interval behind these protocol timeouts. connectionsCheckingInterval controls how often Node checks connections for expired header or request deadlines. The default is 30 seconds. Timeout enforcement is deadline-based, but Node checks those deadlines periodically inside the server. For normal application reasoning, configure the timeout values. Reach for the interval only when you are tuning many long-lived or slow incoming connections and understand the cost of scanning them.
Timeouts interact with body reading. A handler that delays reading a body can leave bytes backed up. The client may still be sending. Node's parser and stream state still need the body to complete before the message is complete.
http.createServer(async (req, res) => {
await new Promise(resolve => setTimeout(resolve, 500));
const body = await readBody(req);
res.end(String(body.length));
});The delay happens after the request head is parsed. During that delay, body bytes may arrive and buffer through the socket, parser, and request stream path. For large bodies, that turns into memory, backpressure, and timing behavior at the same time. If the request timeout expires while the full request remains incomplete, Node owns the timeout response path.
Header timeout failures are usually invisible to route code. The incomplete request head leaves route code with no req.url. Log them at the server layer. A request timeout can also stay outside the listener when the server reaches its full-message deadline before a usable request head exists. After the listener runs, a slow body timeout appears around body-reading state - close, error, and an ended connection.
The raw socket timeout uses a lower-level policy -
server.setTimeout(30_000, socket => {
console.warn('inactive socket', socket.remoteAddress);
socket.destroy();
});Adding the callback means your code handles the timed-out socket. That is a connection-level policy. It is useful for diagnostics. Keep it separate from the HTTP parser's header and request deadlines.
A practical timeout setup usually starts with protocol deadlines and then adds a socket inactivity policy only when the service has a reason. APIs receiving JSON bodies often want shorter headersTimeout and a bounded requestTimeout. Upload endpoints may need a longer request deadline plus stricter byte limits in the body reader. Those choices live at the service edge. Node enforces the values you configure.
A Lifecycle Trace In Logs
A useful request trace names the object whose state changed. That keeps logs from mixing request completion, response completion, and socket completion into one vague event.
http.createServer((req, res) => {
const id = `${req.socket.remotePort}:${Date.now()}`;
req.on('close', () => log(id, 'req', req.complete));
res.on('finish', () => log(id, 'res finish'));
res.on('close', () => log(id, 'res close'));
res.end('ok\n');
});The request close line records whether the inbound HTTP message completed. The response finish line records that Node flushed the outgoing message to the underlying system. The response close line records the response object's close state. A socket-level close listener would record the connection ending, which can happen after one exchange or after several exchanges on a reusable connection.
This style saves time during incident debugging. A log that only says "request closed" is too vague. Did the body complete? Did the client cut off the upload? Did the connection close after the response? A log that records req.complete, res.writableEnded, finish, close, and the socket endpoint gives you enough state to place the failure.
For a successful tiny request, the order often looks simple - request head parsed, handler runs, response ends, response finishes, request is complete, socket waits or closes. Under load, close events can appear in less obvious order because sockets and streams report separate layers. Log facts. Avoid guessing too much from one event.
Add the socket only when you need connection-level context -
http.createServer((req, res) => {
const port = req.socket.remotePort;
res.on('close', () => log('socket peer', port));
res.end('ok\n');
});That port identifies the peer endpoint for this connection at the time the request ran. If the response detaches from the socket after end(), earlier capture keeps the diagnostic value. For long-lived keep-alive connections, one remote port can appear across multiple request traces.
The same trace makes early failures easier to see through missing later events. A clientError counter with no request trace means the parser rejected input before IncomingMessage. A timeout metric with no handler trace means the request head or whole message missed a server deadline. A dropRequest log with a socket endpoint means the connection crossed a per-socket request limit. Missing events are useful when each lifecycle step is named.
Ending One Exchange
An HTTP exchange ends when the request body state and response state both reach an end state.
For a small GET with an empty body, the request can already be complete when the handler runs. With optimizeEmptyRequests, the readable side can already be ended. The response completes when your code calls res.end() and Node flushes the outgoing message.
For a POST, the handler may send a response after reading the full body -
http.createServer(async (req, res) => {
const body = await readBody(req);
res.setHeader('Content-Type', 'text/plain');
res.end(`bytes=${Buffer.byteLength(body)}\n`);
});req.complete should be true after the full body is received and parsed. res.writableEnded becomes true after res.end() is called. res.writableFinished becomes true when all data has flushed to the underlying system, just before finish.
Aborted clients disturb that clean path. A client can close the connection while sending a body. The request can close with req.complete false. The response can close before finish. A write can fail because the peer has gone away. Those cases are normal network outcomes, so server code should place cleanup on close and success metrics on finish carefully.
Unread bodies affect reuse. HTTP/1.1 connection reuse depends on clean message endings. If your code rejects a request and leaves unread body bytes in the connection, the server still has to account for those bytes before treating the socket as ready for another request. Node can drain, close, or mark the connection for closure depending on state. The application lesson is simple - consume, intentionally discard, or close. Leaving the request body in limbo creates confusing reuse behavior.
Early responses are valid. A server can reject a request based on headers and send a final response before reading the whole body. The connection decision becomes the hard part. If body bytes are still in flight, those bytes still belong to the rejected request. Reusing the same socket requires the server to reach a clean message end. Closing is often the clearest outcome for early rejection of large bodies.
A normal successful exchange leaves this state -
req.complete === true
res.writableEnded === true
response finish fires
request close fires
socket remains open or closes by policyThose flags and events give you a small diagnostic set. req.complete tells you whether the inbound message completed. res.writableEnded tells you whether your code ended the response. finish tells you Node flushed the outgoing message to the underlying system. Socket close tells you the connection ended.
Use event combinations instead of one total success signal. A response can finish after the client has already gone away from your application's point of view, depending on timing and buffering. A request can complete and then the response can fail during write. A socket can close after a clean response because the connection policy asked it to. Place each event in the lifecycle before treating it as success or failure.
The per-exchange path ends like this -
request head parsed
request body completes or connection ends
response head commits
response body ends
socket becomes reusable or closesThe final decision depends on HTTP connection rules, server options, parser state, and socket state together. Subchapter 5 spends a full section on reusable connections. For this lifecycle, keep the practical model in mind - IncomingMessage and ServerResponse are per-exchange objects, and the socket can outlive them when HTTP/1.1 reuse stays valid.
Once you keep those lifetimes separate, the Node API gets much easier to read. req is the parsed inbound message. res is the outbound message your code is building. req.socket is the connection carrying them. The server accepts connections and emits events. Bugs become easier to place because each object owns a separate part of the lifecycle.