Get E-Book
HTTP Servers, Clients & Proxies

http.Server Request/Response Lifecycle

Ishtmeet Singh @ishtms/June 10, 2026/47 min read
#nodejs#http#server#request#response

You might expect http.createServer() to start an HTTP server. It does not. The call builds the JavaScript object that represents the server, and that is the whole of what it does. The server has no bound port and accepts no connections until you call .listen().

js
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 takes connected byte streams from node:net, reads HTTP messages out of those bytes, and hands your code one request object and one response object per exchange.

http.createServer() builds an http.Server. You can pass options, a request listener, or both. The request listener is the (req, res) => {} function above, and Node registers it on the server's request event for you.

So you hold a usable server object immediately, even though it is not yet attached to any port.

Before you call .listen(), everything the server holds lives on the JavaScript side. That includes its EventEmitter listeners, the HTTP options you passed, timeout values, parser settings, and the connection handler it inherits from net.Server. Calling .listen() wires that same object down to real socket state through the net.Server path from Chapter 9. From then on the OS can accept TCP connections for the bound address and port, and Node wraps each accepted connection, attaches HTTP parser state, and starts turning bytes into request events.

It helps to keep these layers separate in your head when something breaks. Failing to bind the port is a startup problem and never reaches the parser. A malformed request line, by contrast, is caught well inside the HTTP parser, long after the socket connected fine. And an exception thrown inside your handler is ordinary application code going wrong, with both the socket and the parser perfectly healthy. The same server object is involved every time, so the trick is recognizing which of those three layers you are actually standing in.

The Server Object

http.Server extends net.Server, and that inheritance explains a lot of its surface. The methods you reach for, server.listen(), server.close(), server.address(), and the server.listening property, all come from net.Server, as does the lower-level connection event. What HTTP layers on top is request parsing and response writing, built over the accepted-socket machinery from Chapter 9.

From top to bottom, the layers stack up as follows.

text
http.Server
  -> net.Server behavior
  -> accepted net.Socket
  -> HTTP parser
  -> IncomingMessage + ServerResponse
  -> request event

http.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 by hand.

js
const server = http.createServer();

server.on('request', (req, res) => {
  res.end('same path\n');
});

Both forms end up on the same event, so the choice is cosmetic until you need the named event for something specific, like attaching checkContinue, clientError, or dropRequest listeners, wiring up instrumentation, or letting a test hook the event directly.

The events on an HTTP server fall into two groups. One group rides up from the accepted-socket path, the connection and close events, the timeout handling, and server.close(). The other group comes from the protocol once bytes are parsed, which is where request, checkContinue, clientError, dropRequest, connect, and upgrade live. Most handlers you write only ever touch request. The tunneling and upgrade events get their own treatment in later subchapters, so they stay in the background for now.

The server stores its options up front, before any connection arrives. A few of them tune the socket, like noDelay, keepAlive, and highWaterMark. The rest tune HTTP behavior, including requestTimeout, headersTimeout, IncomingMessage, ServerResponse, rejectNonStandardBodyWrites, and optimizeEmptyRequests.

Most application code passes a request listener and leaves the options at their defaults. Once in a while you will want to supply your own IncomingMessage or ServerResponse subclass, and that works because Node builds those objects fresh for every request. It keeps your constructor choices on the server and reaches for them later, when a parsed request needs real request and response objects.

All of this state is per-server, not global. You can run two HTTP servers in the same process, give each its own headersTimeout, and bind them to different ports. Every accepted socket remembers which server accepted it, so its parser and timeout behavior come from that one server's options.

The constructor also installs one internal connection listener, and that listener is what joins net.Server to the HTTP machinery. You are free to listen for connection yourself, though ordinary HTTP code seldom has a reason to. Internally, that listener takes the accepted net.Socket and gets it ready for HTTP parsing.

Because that internal listener sits between the two layers, an HTTP server can hand you both low-level and protocol-level events. A connection event fires when a socket reaches the server, and a request event fires only once bytes on that socket have formed a valid HTTP request head. Those two things happen at different times, which is exactly why logging both is handy when you debug. If you ever see connections climbing while the request count stays flat, you are usually looking at clients that connect and then stall before they finish sending headers.

Your request listener runs once per parsed request, not once per connection. A single TCP connection can fire several request events when HTTP/1.1 persistence is in play, which Subchapter 5 gets into properly. Underneath all of it, the connected socket and the request object live on different timelines, and one socket can carry more than one IncomingMessage over its life.

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 that parser is llhttp. Subchapter 3 takes apart its state machine, callback table, leniency flags, and parse-error codes, but you do not need any of that yet. At this level it is enough to know that the parser reads bytes off the accepted socket and reports HTTP progress back up into Node's JavaScript HTTP layer.

A normal request moves through the following sequence.

text
accepted net.Socket
  -> parser attached to socket
  -> request headers parsed
  -> IncomingMessage created
  -> ServerResponse created
  -> server emits request
  -> response writes bytes to socket
  -> exchange completes
Sequence of one HTTP request lifecycle across client, socket and parser, server, and handler.
The handler starts the moment the head is parsed and the request event fires. Body bytes keep arriving as req data and end events while the handler already runs. The res finish event means the bytes were flushed locally, not that the client received them.

The 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 timing trips people up on uploads and slow clients. Your handler can start running while body bytes are still on the way.

For each request, Node creates two objects, an http.IncomingMessage for the inbound message and an http.ServerResponse for the outbound one. The IncomingMessage is the request as the server sees it, and the ServerResponse is the reply your code fills in for that request.

Both objects belong to a single HTTP exchange and no other.

The socket sits underneath both and can outlive them. Its parser stays attached for as long as HTTP keeps running on that connection, while the request and response only span the one parsed exchange. Once the response finishes and Node has settled the state of the request body, it can detach the response from the socket and then either prepare for another request or close the connection.

This split between socket and exchange is behind a logging result that catches people out.

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

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

Routing and validation policy are a Chapter 12 topic. As far as the lifecycle goes, the point is just that IncomingMessage hands you raw request target text, and turning that into a route 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 is why a single handler can run two flows at once.

js
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 names of these internal properties shift between Node releases, but the model behind them holds steady. Socket state, parser state, the current incoming message, any queued outgoing message, and stream pressure all meet in the HTTP server layer.

When the parser reports a new request, Node has to produce a few things together. There is a request object to hold incoming data, a response object to hold outgoing data, and a link tying the two together. The request keeps hold of its socket and the parsed request-head fields, while the response keeps a reference back to the request and later writes through that same socket. With both in hand, the server emits request and passes them to your listener.

Your listener starts running while Node is still in the middle of HTTP handling, because EventEmitter dispatch is synchronous. If it writes a response straight away, those bytes go out before the original dispatch even returns. A listener that instead attaches body readers and then returns leaves the request stream alive, and Node keeps feeding it body bytes as the socket produces them.

A typical request body follows this timeline.

text
request head ready
  -> emit request
  -> handler attaches body work
  -> body chunks continue later
  -> response finishes later

Head and body do not arrive on the same schedule. The headers are guaranteed to exist before request fires, but the body bytes might be fully buffered, still trickling in, already complete, entirely absent, or cut off partway. If your handler assumes that a fired request event means the entire request has landed, it will break the moment someone uploads a file. All the event actually promises is that Node now holds a valid request object and a valid response object.

Node also has to keep the response queue from growing without bound. When your code writes a large response over a slow socket, the response object tracks how much is backed up on the writable side. And if a second parsed request is already waiting on that same connection, response order still has to be preserved, because HTTP/1.1 sends responses back in the order the requests came in. So Node's socket-level HTTP state holds the outgoing messages and drains them one after another, in order.

Request bodies create pressure from the other direction too. When a handler stops reading the body, the readable side of IncomingMessage starts to fill, and that backup can pause reads from the underlying socket. Pausing protects memory, but it also strands any later requests already sent on that connection behind the unread body bytes. The parser has to know where the current message ends before the connection can move on cleanly 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.

Writing the response works incrementally as well. ServerResponse will take headers, a status, and body chunks long before any of those bytes reach the kernel. Calling res.end() finishes the outgoing HTTP message as far as JavaScript is concerned, even though the socket write can complete later and the peer can still reset before it reads a thing. This gap between the two is exactly why response finalization and connection finalization fire as separate events.

The socket can also fail before Node ever builds a request object. A parse error in the method token or in the headers shows up as clientError, since the parser gave up before any valid IncomingMessage came to exist. Other failures happen mid-flight, like a TCP reset arriving while the body is still streaming, or a timeout firing while the headers are only half-received. Every one of these enters Node down at the socket and parser layer and then surfaces through the HTTP server events this chapter covers further on.

Errors from your own handler need the same attention. A synchronous throw out of the request listener happens right there during EventEmitter dispatch, whereas a rejected promise from async work surfaces later, after the listener has already returned. Node's HTTP server can opt into EventEmitter capture-rejection behavior, but your application code should still finish the response, or destroy it, from its own error path.

js
http.createServer(async (req, res) => {
  try {
    res.end(await render(req));
  } catch (err) {
    fail(res, err);
  }
});

That fail() helper, which shows up again in the header section, can pick a 500 response while headers are still unsent, or tear the response down if it has already started writing. Logic like that lives in your server code, because your application, not Node, decides what an error response should look like.

IncomingMessage Is The Request

http.IncomingMessage is what Node hands you as req inside a server handler, and it extends Readable. The stream half of it carries the request body, and its plain properties carry the parsed request head.

js
http.createServer((req, res) => {
  console.log(req.method);
  console.log(req.url);
  console.log(req.headers.host);
  res.end('ok\n');
});

Here req.method holds the method string and req.url holds the request target string, while req.headers is a lowercased object built from the parsed header section. Because all of these come straight out of the request head, you can read them the instant the request event fires.

message.headers is convenient, but it does not keep every detail exactly as it arrived. It lowercases header names, joins some duplicate fields, and drops others according to Node's header-handling rules, unless you opt into duplicate joining. The one exception is set-cookie, which stays an array because that header genuinely carries separate values. For everyday routing and metadata checks, req.headers is the one you want.

message.rawHeaders keeps the received header names and values together in one flat array.

js
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');
});

In that array the even indexes are header names and the odd indexes are the matching values, with each name kept in its original case and every duplicate left separate. Reach for rawHeaders when you are debugging exactly what a client sent, keeping header order intact for logs, or writing something that depends on the received spelling. For most request handling, stick with headers, since lowercased keys keep you from making case-sensitivity mistakes in your branches.

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.

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

You can read req until it ends and treat everything it gave you as a single body, and that is the whole mental model you need. Node's parser handles the byte-level message end and builds the next request object once the next head is ready.

Backpressure has not gone anywhere. A slow body reader lets data pile up in the readable stream buffer and in the lower socket buffers, and a handler that never reads the body can hurt both memory use and connection reuse. So a server that wants to reject a request carrying a body needs a deliberate plan for those bytes, whether that is draining a bounded amount, destroying the request and its socket, or leaning on Node's connection-close path. Walking away from an unread body just makes diagnostics confusing, since the response can finish while the socket still has inbound bytes waiting on it.

Tiny handlers often skip reading the body for methods that usually carry none.

js
http.createServer((req, res) => {
  if (req.method === 'GET') {
    res.end('read-only\n');
    return;
  }

  req.resume();
  res.end('ignored\n');
});

req.resume() throws the body away by switching the readable stream into flowing mode. This example does the bluntest possible thing. It swallows whatever body shows up so the connection does not get stuck behind unread data. Real handlers still need a cap, though, because draining an unbounded body can burn bandwidth and memory once the input turns hostile.

A bounded discard makes that decision explicit.

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

You have two signals to tell when a request finished. The close event fires once the request has reached its end state at the IncomingMessage level in current Node, and message.complete tells you whether Node actually received and parsed a full HTTP message.

js
req.on('close', () => {
  if (req.complete) console.log('full request received');
  else console.log('client cut off the request');
});

That check is handy when you are chasing upload problems. On its own, a close event only tells you the request object reached its end state, while req.complete is what tells you whether the peer managed to send the whole message before the connection went away.

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.

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

This pays off most on high-volume APIs where a lot of requests are headers only, since it skips an empty run of body events. A for await (const chunk of req) loop keeps working either way, because an already-ended readable simply ends the loop right away. The one thing to watch is code that adds an end listener after the request has already ended, which should check the stream state instead of waiting for an event that will never come.

Either way, the request fields are still there for you.

js
http.createServer({ optimizeEmptyRequests: true }, (req, res) => {
  console.log(req.method, req.url, req.readableEnded);
  res.end('ok\n');
});

That last value reports whether the request body side has already ended. None of it changes the response state, the socket state, or any later keep-alive reuse, which all move independently.

IncomingMessage also keeps a socket reference. Use it when you need connection diagnostics.

js
http.createServer((req, res) => {
  const { remoteAddress, remotePort } = req.socket;
  res.end(`${remoteAddress}:${remotePort}\n`);
});

That field points back at the connection, so think of it as connection metadata. In normal server code, do not mix raw socket reads with HTTP request reads. While the connection is in HTTP mode, the parser is the one reading socket data, and the request body already reaches you through the IncomingMessage readable stream anyway.

ServerResponse Is The Response Writer

res is an http.ServerResponse. 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 ever touch ServerResponse directly.

js
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) queues a header for the response head that goes out later. The value can be a string, a number, or an array of strings, and Node validates both the name and the value, converting numbers to their network form when the time comes. Calling it again for the same header name replaces whatever you queued before.

response.write(chunk) writes body data, and the first such write also commits any headers you still had pending. Its return value is the writable backpressure signal. A true there means the data made it through the local writable path, and a false means it is sitting queued in user memory, with a drain event to come 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.

js
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 carries its own events as well. finish means Node has handed the final response bytes to the underlying system for transmission, though whether the client received them is still a separate network-level question. The close event, on the other hand, means the response either completed or the underlying connection ended early. When those two do not agree, comparing finish against close is how you locate the failure.

OutgoingMessage deserves a name of its own, because a lot of the response methods actually come from it. Header storage, headersSent, flushHeaders(), write(), end(), writableEnded, and writableFinished are all part of that outgoing-message machinery. What ServerResponse adds on top is the server-specific behavior, like the link to the associated request, the default status code, the automatic date header, and the bodyless-response rules for HEAD, 204, 304, and 1xx replies.

A response begins life as metadata sitting in front of an empty body. Up until the moment of commit, statusCode, statusMessage, and your queued headers are nothing more than JavaScript state. The act of committing serializes the status line and header section into bytes, and from there body writes just append more bytes to the outgoing message.

You can build a response through implicit headers.

js
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('missing\n');

This reads well when the response metadata gets assembled across several branches, and the end() call is what commits the pending headers at the end.

You can also send the response head explicitly.

js
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('missing\n');

Doing it this way is tighter when you already know the status and headers in one place. Whichever form you pick, the bytes on the connection come out the same, a status line, the header section, a blank line, and then the optional body.

Byte counts become important the moment you set Content-Length yourself. That header is a count of bytes, while JavaScript's string length counts UTF-16 code units instead, so reach for Buffer.byteLength(), since a single UTF-8 character can take up more than one byte.

js
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 expressed with writeHead().

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

Pick setHeader() when you want to build the metadata up over time and inspect it before anything commits. writeHead() fits better when the status and headers are already settled at one point in the code. Mixing the two is allowed, as long as the precedence is something you chose rather than stumbled into.

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.

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

This guard earns its keep with helper functions that always write a JSON body. A generic send(res, status, value) helper can easily emit bytes for a 204, or for a HEAD request that happened to reuse a GET handler. Enable the option and that mistake throws right next to the helper call, where you will actually notice it. Without it, the server keeps looking healthy while your tests quietly miss that the handler built a body the protocol forbids.

HEAD trips people up. The server is supposed to send the very same response metadata a GET would, except the final response carries no body at all. Your code still has to call end(), just ending the message with an empty body.

js
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 runs to completion, with a body that happens to be zero bytes long.

The response keeps a socket reference too, while it is still active.

js
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 stay editable only up to the point where Node commits the response head, after which they are fixed.

Any of these calls can trigger that commit.

text
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 tells you whether the response head has already been committed.

js
http.createServer((req, res) => {
  res.write('partial\n');
  console.log(res.headersSent);
  res.setHeader('X-Late', '1');
  res.end();
});

What goes wrong here is the late setHeader(). That first res.write() already pushed Node into generating and sending the response head, so headersSent is true by the time the next line runs. Trying to change a header now throws, because the metadata has already been serialized and sent.

Move all of the response metadata ahead of the first body write.

js
http.createServer((req, res) => {
  res.statusCode = 202;
  res.setHeader('Content-Type', 'text/plain');
  res.write('accepted\n');
  res.end();
});

This timing turns into irritating bugs when async branches race the first write. If one branch is already streaming body data while another tries to add a cookie or a cache or trace header a moment later, the body write gets there first and freezes the headers. From that point on, headersSent will tell you the head has already gone out.

flushHeaders() makes the commit deliberate.

js
http.createServer((req, res) => {
  res.setHeader('Content-Type', 'text/plain');
  res.flushHeaders();

  setTimeout(() => res.end('later\n'), 100);
});

In this version the early flush is something you are choosing to do. The client gets to see the status and headers before the body shows up, which helps for streaming responses. The cost is that every later metadata change has to move somewhere earlier, ahead of the flush.

Internally, ServerResponse holds the outgoing headers as JavaScript state until commit. At that point Node serializes the status line and header section into bytes, joins them with the first body chunk where it can, and writes the whole thing out through the socket. After those bytes enter the socket write path, the JavaScript header fields only describe what already went out, and editing them changes nothing.

Commit can also happen by way of end() with data.

js
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 a precedence rule people forget. If you set a header with setHeader() first and then pass that same header into writeHead(), the value from writeHead() is the one that goes out.

js
res.setHeader('Content-Type', 'text/html');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok\n');

The client receives text/plain. That rule helps when a lower-level helper sets defaults and the final branch picks the exact status and content type. It hurts when two helpers disagree without either one knowing. Where you can, keep the decision about a given header in one place.

For a defensive error handler, headersSent is the value you want to branch on.

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

Validation also happens before commit. An invalid field name or value throws, and that error is part of building your response. When a value came from user data, validate or encode it before it ever reaches setHeader(). Node guards the wire format itself, while the meaning and correctness of the response are left to your code.

In a clean response path, one piece of code is in charge of the metadata, with the route handler settling the status, the serializer settling the content type and body, and a final writer making the end() call. Spread those jobs across unrelated callbacks and the first body write can freeze the headers earlier than the later code expects. headersSent lets you recover after the fact, but it is far easier to settle the headers before the stream ever starts.

Expect: 100-continue

With Expect: 100-continue, the client asks permission before sending the body, and the server gets to approve or refuse it first.

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 hands you that decision through checkContinue.

js
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 hands you the same pair of objects as request, an IncomingMessage and a ServerResponse. Once your code handles checkContinue, that whole exchange comes into your server through the checkContinue handler rather than 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 come down to size, auth, or content type. Base the decision on request-head data only, because reading the body before you send 100 Continue undoes the whole point of asking first.

Sending a final response before writeContinue() has a knock-on effect on connection reuse. If you send a final status while the client still believes it has permission to send a body, the connection turns into a weak candidate for reuse, since the client might still push body bytes onto it, and Node treats that case cautiously. So in your own code, once you reject a continued request, end the response cleanly and expect that 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 one common function from both paths.

js
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 land before Node ever gets to build an IncomingMessage for you.

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.

js
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 gets an error and the socket. Once you attach it, closing the connection for that error is on you. If the socket is still writable, write a valid raw HTTP response and then close or destroy it. What you have here is raw socket access rather than 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 just destroy the socket right after logging.

js
server.on('clientError', (err, socket) => {
  console.warn(err.code, socket.remoteAddress);
  socket.destroy();
});

Destroying outright is reasonable if you would rather hard-close malformed input. Sending back a tiny 400 is more helpful when you are debugging a client by hand. In both cases the request listener never runs, because the parser stopped before it could produce 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.

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

These same errors also mark where framework code actually starts. Middleware only runs after request fires. Anything the parser rejects beforehand comes out as clientError, a request dropped for crossing a per-socket limit comes out as dropRequest, and an exception thrown inside middleware belongs to your handler or framework. Each of those is a different place a request can fall over, and they do not overlap.

That separation has real consequences for metrics. A dashboard that only counts handler-level 4xx responses will completely miss malformed requests that were rejected before request ever fired. If you want visibility into bad clients, scanners, or proxies sending invalid HTTP, put a counter on clientError. Keep a separate counter on dropRequest once you set maxRequestsPerSocket, since each of those drops means a reusable connection crossed a limit you defined.

The status code itself often points at where the request died. Your handler is free to return any application response it likes, but the other paths are more fixed. A clientError usually writes a raw 400 or just closes, header overflow defaults to 431, and a dropped request gets a 503.

clientError also fires for ordinary socket trouble during parsing, so do not read every one as an attack. It might be a mobile client dropping its connection partway through the headers, or a load balancer closing an idle connection at the same instant the client starts a fresh request. All the event really says is that the socket left the normal request path while Node was still working on HTTP input. From there, the error code and the bytes-parsed count are your next clue.

Flowchart of where each HTTP server failure surface branches off the parse to response path.
Each failure has a fixed exit point. clientError (400, or 431 on header overflow) and headersTimeout (408) fire before the IncomingMessage exists. dropRequest (503) and requestTimeout (408) fire after it is created. The request object is the line that separates the two groups.

Request And Header Timeouts

On top of the raw socket timeout, an HTTP server runs its own protocol-level timeouts.

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.

js
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 guard separate phases of the request.

text
headersTimeout
  -> request line and header section must complete

requestTimeout
  -> full request, including body, must complete

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

It pays to keep these names precise when you review code. A header timeout means the headers never finished arriving. A request timeout means the whole request, body and all, never finished. Socket timeout is something different again, just plain inactivity on the connection, while a keep-alive timeout is an idle but still reusable connection sitting there after a response went out. Four different states, and each one earns its own log line.

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.

Behind those protocol timeouts sits a checker interval. connectionsCheckingInterval decides how often Node sweeps connections looking for expired header or request deadlines, and it defaults to 30 seconds. The deadlines themselves are absolute, but Node only notices them on each periodic sweep. For ordinary reasoning, set the timeout values and leave the interval alone. You reach for it only when you are tuning a server with many slow or long-lived incoming connections and you understand what scanning them all costs.

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.

js
http.createServer(async (req, res) => {
  await new Promise(resolve => setTimeout(resolve, 500));
  const body = await readBody(req);
  res.end(String(body.length));
});

That delay sits after the request head has been parsed. While it runs, body bytes can keep arriving and buffering through the socket, the parser, and the request stream. For a large body, that means memory, backpressure, and timing all in play together. And if the request timeout fires while the full request is still incomplete, the timeout response is Node's to handle, not yours.

Header timeout failures are usually invisible to route code, because an incomplete request head means there is no req.url to work with, so log these at the server layer instead. A request timeout can also stay outside your listener when the server hits its full-message deadline before any usable request head exists. Once the listener is already running, a slow-body timeout shows up around the body-reading state, as a close, an error, and an ended connection.

The raw socket timeout works at a lower level.

js
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 workable setup usually starts from the protocol deadlines and only adds a socket inactivity policy when the service genuinely needs one. An API taking JSON bodies tends to want a shorter headersTimeout and a bounded requestTimeout, whereas an upload endpoint often needs a longer request deadline paired with stricter byte limits in its body reader. Those are decisions you make at the edge of the service, and Node simply enforces whatever values you give it.

A Lifecycle Trace In Logs

A trace is most useful when it names the exact object whose state just changed. That alone stops your logs from blurring request completion, response completion, and socket completion together into one vague line.

js
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');
});

That request close line captures whether the inbound HTTP message actually completed. The finish line on the response records the moment Node flushed the outgoing message to the underlying system, and the response close line records the response object reaching its own close state. A socket-level close listener, if you added one, would mark the connection itself ending, which can come after a single exchange or after several of them on a reused connection.

This kind of trace saves real time in an incident. A log line that just says request closed leaves you guessing at all the things you actually need to know, like whether the body finished, whether the client cut the upload off early, or whether the connection dropped only after the response went out. A line that instead carries req.complete, res.writableEnded, finish, close, and the socket endpoint gives you enough state to pin down where it failed.

For a small successful request, the order tends to read cleanly. The head is parsed, the handler runs, the response ends and then finishes, the request reads as complete, and the socket either waits or closes. Under load the close events can show up in a less obvious order, because sockets and streams report from separate layers. The safe habit is to log the facts and not infer too much from any single event.

Add the socket itself only when you need connection-level context.

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

That same trace also lets the absence of later events tell you something. When you see a clientError counter tick with no matching request trace, the parser rejected the input before any IncomingMessage existed. A timeout metric with no handler trace points to a request head or whole message that missed a server deadline. And a dropRequest log carrying a socket endpoint means that connection went past a per-socket request limit. Once every lifecycle step has a name, the steps that never logged become information in their own right.

Ending One Exchange

An exchange is only over once both sides have reached an end state, the request body on one side and the response on the other.

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 often sends its response only after reading the full body.

js
http.createServer(async (req, res) => {
  const body = await readBody(req);
  res.setHeader('Content-Type', 'text/plain');
  res.end(`bytes=${Buffer.byteLength(body)}\n`);
});

After the full body is received and parsed, req.complete should read true. Calling res.end() flips res.writableEnded to true, and res.writableFinished only turns true once every byte has flushed to the underlying system, which happens just before finish fires.

Aborted clients break that clean path in a few ways. A client can close the connection halfway through sending a body, which leaves the request closing with req.complete still false and the response closing before finish ever fires, and a pending write can fail outright because the peer is already gone. None of this is exotic, it is normal network behavior, so put your cleanup on close and be careful about hanging success metrics off finish.

Unread bodies feed back into reuse. HTTP/1.1 can only reuse a connection when each message ends cleanly. If your code rejects a request but leaves its body bytes unread on the connection, the server still has to account for those bytes before it can hand the socket to another request, and depending on state Node may drain them, close, or flag the connection for closing. So your code has to make an actual choice about the body, reading it through, discarding a bounded amount of it, or closing the connection outright. Leaving it half-read is exactly what produces confusing reuse behavior later.

Responding early is completely legal. A server can look at the headers, decide to reject, and send a final response before it has read the whole body. The tricky part is the connection decision that follows. Any body bytes still on the way in continue to belong to the rejected request, and reusing that socket means the server first has to reach a clean message end. For early rejection of a large body, just closing the connection is often the clearest way out.

A normal successful exchange settles into this state.

text
req.complete === true
res.writableEnded === true
response finish fires
request close fires
socket remains open or closes by policy

Together those flags and events make a compact diagnostic set. req.complete answers whether the inbound message finished, while res.writableEnded answers whether your own code ended the response. The finish event reports that Node flushed the outgoing message to the underlying system, and a socket close reports that the connection itself ended.

Lean on combinations of these events rather than hunting for one single success signal. Because of timing and buffering, a response can finish even after the client has, from your application's view, already walked away. Sometimes a request completes cleanly and then its response fails during the write. Other times a socket closes right after a perfectly good response, only because the connection policy called for it. So fit each event into the lifecycle before you read it as success or failure.

The per-exchange path winds down in this order.

text
request head parsed
request body completes or connection ends
response head commits
response body ends
socket becomes reusable or closes

What happens at the very end depends on the HTTP connection rules, the server options, the parser state, and the socket state all at once. Subchapter 5 gives reusable connections a full section of their own. For the lifecycle here, the model stays small. IncomingMessage and ServerResponse are per-exchange objects, and the socket underneath can outlive both of them whenever HTTP/1.1 reuse is still valid.

Once you hold those lifetimes apart in your head, the rest of the Node API reads much more naturally. req is the parsed inbound message, res is the outbound message you are filling in, and req.socket is the connection carrying both. The server's own job is just to accept connections and emit events. When a bug shows up, it is easier to locate, because each of these objects is responsible for a separate stretch of the lifecycle.