HTTP Parsing with llhttp
A TCP read can stop halfway through an HTTP header. This previous line explains why Node needs an HTTP parser at all.
A socket gives Node raw bytes. HTTP gives those bytes rules. Node needs something in the middle that can remember where it stopped, accept more bytes later, and call JavaScript only when enough of the HTTP message has been understood.
Here is a tiny version of the problem -
socket.write('GET /slow HTTP/1.1\r\nHos');
socket.write('t: example.test\r\n\r\n');The first write ends after Hos. Node has only seen the beginning of the Host header name. The header is not complete yet. The second write finishes Host, adds the value, and then adds the blank line that ends the header section.
A socket read can contain many kinds of partial or complete HTTP data -
half of a request line
one complete header section
headers plus part of the body
two complete requests plus part of a thirdThe parser has to handle all of these.
TCP does not know or care where an HTTP request starts and ends. It only delivers bytes. Those bytes may arrive in small pieces, large pieces, or several HTTP messages at once. HTTP/1.1 has its own grammar, and the parser uses that grammar to decide what each byte means.
That is why Node does not treat each socket read as one HTTP message. One read might stop before the request line finishes. Another read might contain a full GET request with no body. Another might contain the first few kilobytes of a large upload. Another might contain a complete request and the beginning of the next pipelined request.
Node feeds bytes into llhttp as they arrive. llhttp keeps track of the current HTTP parsing position. If the current buffer ends before the message is complete, llhttp returns after consuming the valid part it received. When Node later feeds the next buffer, llhttp continues from the saved position.
So the unit you care about is parser progress, not socket chunks.
This shows up in real debugging. A TCP capture might show one header spread across multiple packets. A Node stream chunk might contain several header lines. A parser error might point into the middle of the current buffer because earlier bytes in that same buffer were accepted. The parser decides what each byte belongs to based on the HTTP state it has already seen.
An HTTP parser is the protocol component that consumes HTTP/1.1 bytes and reports parsed pieces - method, URL text, version, headers, body bytes, trailers, and message completion. In Node's HTTP/1.1 implementation, that parser is llhttp.
llhttp is the native HTTP/1.1 parser library used by node:http. Its parser definition is maintained in TypeScript in the llhttp project, then generated into C so Node can compile and embed it. Node wraps that parser with its own native and JavaScript HTTP code, attaches it to sockets, and turns parser events into http.IncomingMessage and http.ServerResponse behavior.
The path looks like this -
connected socket bytes
-> Node HTTP parser wrapper
-> llhttp execution
-> parser callbacks
-> IncomingMessage and ServerResponse
-> request listener or client response callbackA parser callback is a function Node wires into the parser so the parser can report progress while it consumes bytes. llhttp has callbacks for message start, URL spans, header field spans, header value spans, headers complete, body chunks, chunk positions, trailers, and message complete. Node's wrapper receives those callbacks and updates Node-owned HTTP objects.
This timing explains a lot of Node HTTP behavior. Your request listener runs after the parser has accepted the request line and header section. Parser failures can happen before your handler ever gets a req object. Body bytes can keep arriving after your handler starts. Trailers become visible only after the body finishes. Upgrade requests stop the normal HTTP path and hand the socket to code that owns the next protocol.
Bytes Before Objects
http.createServer() creates the server object and stores the request listener. .listen() starts accepting sockets. Once a connected socket reaches the HTTP server, Node attaches an HTTP parser to that socket and starts feeding readable bytes into it.
There are three things involved here, and each one owns a different job -
net.Socket
owns connection state and readable bytes
HTTP parser
owns partial HTTP/1.1 parse state
IncomingMessage
exposes parsed headers and body as JavaScript stateThe socket is the transport object. It knows about connection state and bytes. The parser is the protocol object. It knows how to read HTTP/1.1 grammar. IncomingMessage is the JavaScript object your handler receives for one parsed HTTP message.
Keeping these jobs separate lets one connection carry more than one request. A keep-alive socket can carry a request, then another request later. Node can reset parser state between messages while keeping the same socket open. Each request still gets its own IncomingMessage.
Parsing starts before JavaScript sees the request event.
Node reads bytes from the socket into buffers. The HTTP parser consumes those buffers. llhttp moves through HTTP grammar and reports parsed pieces through callbacks. Node collects header pieces, decides when headers are complete, creates or fills the IncomingMessage, pairs it with a ServerResponse, and then emits the server's request event.
The parser is attached at the HTTP layer, so normal server code receives req data instead of raw socket bytes. The socket still exists at req.socket, but the HTTP server has parser machinery connected to the readable side of that socket. If application code reads directly from the socket, it can interfere with the HTTP server's ownership of those bytes. For HTTP server code, the request stream is the body interface.
There is one ordering detail worth keeping in your head. Node may accept a TCP connection and emit the server's lower-level connection event before any HTTP request has been parsed. At that point, the socket exists and the parser can be attached, but the client may not have sent enough HTTP bytes yet. The request event waits until the parser has enough data to finish the request line and headers. A slow client can keep the socket open while the parser waits for the blank line that ends the headers. server.headersTimeout controls that wait.
This handler starts after header parsing -
import http from 'node:http';
http.createServer((req, res) => {
console.log(req.method, req.url);
res.end('ok\n');
}).listen(3000);When the callback runs, req.method, req.url, req.httpVersion, req.headers, and req.rawHeaders are already available. The body stream may still be incomplete. For a large upload, headers usually arrive first and body bytes keep arriving later.
Client responses use the same parser idea from the other side. http.request() writes an outgoing request. Then the socket receives response bytes. The parser reads the status line, headers, and response body framing. Node emits the client's response event with an IncomingMessage representing the response.
Errors surface where the parser is currently working. A malformed method token in an incoming request can fire clientError before the server emits request. A malformed response from an upstream server can emit error on the ClientRequest. In both cases, the parser rejected bytes while Node was still trying to build the HTTP message object.
A good debugging habit is to ask where the failure happened. Socket errors come from the transport path. Parser errors come from HTTP grammar or parser limits. Handler errors happen after Node has already accepted enough bytes to create req and res.
That also explains why a raw net.Server and an http.Server feel so different even though both start with a connected socket. A net.Socket gives you chunks whenever stream consumption asks for them. An HTTP server puts a parser between the socket and your handler. The parser consumes the HTTP framing, removes that framing from the body stream, and exposes message metadata through properties.
For an inbound request, the sequence is -
accept connected socket
-> attach HTTP parser
-> parse request line and headers
-> create request and response objects
-> emit request
-> stream body bytesFor an outbound client response, the sequence is -
assign socket to ClientRequest
-> write request bytes
-> attach response parser
-> parse status line and headers
-> emit response
-> stream response body bytesThe server path and client path mirror each other, but the error event changes. Server parse failures go to clientError because the peer is the client connected to your server. Client parse failures go to the request object's error event because the peer is the upstream server sending a response Node rejected.
The State Machine
llhttp is a parser state machine. It keeps a current state, reads bytes, and moves to the next state based on HTTP grammar. The current state decides which bytes are valid and which callback should fire.
The full state graph is large, but this trace is enough to understand the flow -
start
-> request line
-> header field
-> header value
-> headers complete
-> body or next message
-> trailers
-> message completeFor chunked bodies, the body section has more steps -
chunk size
-> chunk data
-> chunk complete
-> next chunk size
-> trailers
-> message completeThese states survive across socket reads. If Node feeds llhttp a buffer that ends in the middle of a header name, the parser stores that position. The next execute call resumes from that position with the next bytes. That saved state is how Node handles TCP reads that stop halfway through HTTP syntax.
The parser is incremental. llhttp_execute() can parse a full request, part of a request, several pipelined requests, or one request plus the beginning of the next one. It depends entirely on which bytes Node hands it. The parser consumes as much valid input as it can and returns a status code. HPE_OK means the supplied input was consumed. A pause code means parsing stopped deliberately. Another HPE_* code means the parser rejected the input.
Inside the request line, the parser is looking for a method token, a space, request target bytes, another space, and an HTTP version. It does not need the whole message buffered first. It can report spans as it sees them. Node accumulates the parts it needs and stores them on parser or message state.
Header parsing repeats the same pattern for each field. llhttp reads a header field name, sees the : byte, reads the field value, and completes that pair when it reaches the line ending. Node stores the original name and value for rawHeaders, and it also prepares the normalized view that becomes message.headers. The parser keeps going until it sees the blank line after the header section.
Headers complete is the point where Node has enough information to create the main request object. At that moment, Node knows the method, URL text, version, header section, and body framing rules visible in the headers. Node can create the IncomingMessage and decide how the rest of the message body should flow.
For a Content-Length body, parser state tracks how many body bytes remain for the current message. For chunked transfer coding, parser state tracks chunk size lines, chunk data, chunk endings, and optional trailer fields. For messages whose body ends when the connection closes, completion depends on EOF through llhttp_finish() instead of a length counter.
Callbacks are part of the parser contract. Some callbacks report spans, such as "these bytes are part of the URL" or "these bytes are part of a header value." Other callbacks report completion points, such as headers complete, chunk complete, or message complete. Node maps those callbacks onto its HTTP message lifecycle.
Node's HTTP wrapper sits between raw parser callbacks and user code. It owns policy for max header size, duplicate header handling, IncomingMessage construction, body stream pushes, and error events. llhttp owns the byte grammar and state changes.
A span callback can fire multiple times for one logical field. TCP reads can stop halfway through a field. Large fields can cross buffer edges. A URL callback might receive /user in one call and s/42 in another. A header value callback might receive part of a long cookie value, then receive the rest later. Node accumulates those spans until the completion callback says the field is done.
That is why parsers expose both data callbacks and completion callbacks. A data callback says, "these bytes belong to the current field." A completion callback says, "the current field is finished." Node needs both to build stable JavaScript properties.
Header parsing has one more step. The parser sees field names and field values as byte ranges. JavaScript exposes strings. Node has to decode and store them using HTTP's header rules. It also has to preserve enough original information for rawHeaders. That work happens before req.headers feels like a normal object.
The state machine stays inside the parser. JavaScript gets larger lifecycle events. You see request, data, end, clientError, upgrade, and close. llhttp sees smaller protocol positions - method, URL, version, header field, header value, body, chunk size, trailer field, trailer value, complete.
Incremental parsing also explains pipelining. A client can send more than one HTTP/1.1 request on a persistent connection before receiving the first response. The parser can finish one message, return to the start state, and continue consuming bytes already present in the same buffer. Node still has to preserve response ordering at the HTTP layer. Subchapter 5 covers the keep-alive consequences, but the parser work starts with a simple fact - one socket buffer may contain more than one HTTP message.
Partial body state follows the same idea. If Content-Length: 10 arrives and the first body callback reports 4 bytes, the parser still knows that 6 bytes remain for this message. If the socket closes after those 4 bytes, completion fails because the declared body length was only partially received. That is an HTTP message completion problem, separate from JavaScript stream formatting.
Parser pause is a deliberate stop in parsing. llhttp exposes pause states so Node can stop consuming bytes and resume later. Node uses this around accepted upgrades. Stream backpressure sits one layer higher. When the IncomingMessage readable side has buffered enough body data, Node pauses socket reads, so fewer buffers reach llhttp until the stream consumer asks for more. After an accepted upgrade, the HTTP parser has consumed the HTTP request that asked for the upgrade, then parsing stops so the remaining bytes can belong to the upgraded protocol.
Parser reset returns a parser to its start state while keeping its configuration. Node can reset parser state after a message completes and use the parser again for another HTTP/1.1 message on the same connection, or return it to Node's idle parser pool.
Reset is safe because message-owned JavaScript state lives on IncomingMessage, ServerResponse, and related objects. Parser-owned state lives in the parser. Once a message completes and Node has detached message state, the parser can return to its start state for the next message.
The state machine also explains parse errors. A question-mark byte can be valid inside a request target and invalid inside a method token. A carriage return can be valid at a header line ending and invalid in the middle of a header value. The current parser state gives each byte its meaning.
So a parse error usually means the parser was checking one specific HTTP rule, and the next byte broke that rule. Node turns the returned HPE_* code into an error object and surfaces it through the HTTP error path.
From Callback to Message
The parser callback layer is where raw HTTP progress becomes Node object state.
Start with the request line. llhttp reads method bytes, request target bytes, and version bytes. Node stores the method token as req.method, the request target text as req.url, and the HTTP version as req.httpVersion. Major and minor version fields are available too. By the time those fields are stored, the parser has already checked enough grammar to know they are valid HTTP/1.1 pieces.
Then headers arrive as pairs. Node collects each field name and value as parser callbacks report spans. Completion callbacks mark where one pair ends and the next begins. Node keeps the raw pair list for rawHeaders, and it can later compute normalized header views from that list.
When headers complete, Node has enough metadata to create the main request object. It can choose the incoming message class, attach the socket, set request properties, initialize the readable stream side, and allocate the matching outgoing response object for servers. The request listener runs because that work has finished.
The body stream is attached to the same IncomingMessage, but the body bytes may arrive later. Body callbacks push bytes into the readable side. Message complete updates completion state. Close and error paths update stream state and socket state.
The JavaScript code looks small -
http.createServer((req, res) => {
console.log(req.method);
console.log(req.headers);
req.pipe(res);
});That handler touches three outputs from parser materialization. req.method came from the request line. req.headers came from collected header pairs and Node's normalized header view. req as a readable stream receives body bytes after the parser has identified which bytes belong to the body.
The parser also feeds response-side state for clients. A response status line becomes res.statusCode, res.statusMessage, and res.httpVersion. Response headers become the same header views. Response body bytes flow through the response IncomingMessage.
Message construction has to keep per-message state separate from reusable state. The parser might be reused. The socket might be reused. The IncomingMessage belongs to one HTTP message. A second request on the same socket gets a second IncomingMessage. That is how parser reset and keep-alive work without mixing JavaScript request objects.
Node options can alter object construction while keeping the same HTTP grammar. IncomingMessage and ServerResponse options let advanced users provide subclasses. highWaterMark changes stream buffer thresholds. optimizeEmptyRequests changes empty-body stream initialization for requests missing Content-Length and Transfer-Encoding. Those options live around parser output. llhttp still parses the same HTTP grammar.
The callback path also explains why some failures have very little application context. If the parser rejects bytes before headers complete, the stable context is the socket and parser error. If the parser rejects bytes while a body is in progress, Node may already have a request object, but the message can still end incomplete. Either way, the error comes from the parser path.
Header Materialization
Headers start as bytes in the socket buffer. Node exposes several JavaScript views because each one answers a different question.
message.rawHeaders keeps the received header names and values in a single array. Names keep their received casing. Duplicate fields stay duplicated. Ordering stays visible.
[
'Host', 'example.test',
'X-Trace', 'a',
'x-trace', 'b'
]That view is helpful when debugging parser input, proxy forwarding, or duplicate header behavior. It is awkward for ordinary lookup because you have to scan pairs manually.
message.headers gives application code a lower-cased object. Header names become lower-case keys. Values become strings or arrays, depending on the header name and duplicate rules. Node computes this view lazily in current releases, so reading req.headers builds the object only when code asks for it.
Lazy computation saves work for request paths that never need the normalized header object. A handler might only read req.url and stream the body somewhere else. Another handler might read rawHeaders for forwarding. Node can preserve raw parser output and build the normalized object later.
The normalized object uses lower-case keys because HTTP field names are case-insensitive. That makes lookup stable -
const type = req.headers['content-type'];
const host = req.headers.host;Both lines read from the normalized view. The original wire casing remains available in rawHeaders for diagnostics and forwarding.
Duplicate handling is the part that usually surprises people.
Some header names behave like single-value fields in Node's normalized message.headers object. Duplicates for names such as host, content-length, content-type, and authorization are discarded by default. set-cookie stays an array. Duplicate cookie values join with ; . Other duplicate header values join with , .
joinDuplicateHeaders changes the discard behavior for that single-value set. With joinDuplicateHeaders: true, Node joins duplicated values with , instead of dropping later duplicates. The raw view still preserves the original received pairs.
const server = http.createServer({
joinDuplicateHeaders: true
}, (req, res) => {
console.log(req.headers);
console.log(req.headersDistinct);
res.end('ok\n');
});The option changes the normalized headers object. headersDistinct gives a separate view where every value is an array, and duplicate values are kept as separate array elements. That view avoids join and discard policy in the lookup result while still using lower-cased header names.
The three views work like this -
rawHeaders
received names, received casing, pair order
headers
lower-case keys, Node duplicate policy
headersDistinct
lower-case keys, array values, duplicate values retainedPick the view based on the bug you are chasing. Application routing usually wants headers. Header forwarding often needs rawHeaders or deliberate reconstruction. Duplicate-sensitive diagnostics usually want headersDistinct and rawHeaders together.
There is a tradeoff in every view. headersDistinct keeps duplicate values as arrays, but it still normalizes names to lower-case keys. rawHeaders keeps original casing and order, but lookup is manual. message.headers gives convenient lookup, but it applies Node's duplicate rules. These are JavaScript views of parser output, not the parser itself.
Duplicate Content-Length is a good edge case. Body framing depends on the length result, so multiple length declarations can cause the parser to reject the message before routing. The duplicate header views only exist for messages the parser accepts far enough to create the message object.
Outgoing headers use another path. http.validateHeaderName() and http.validateHeaderValue() run the same low-level checks Node applies when you set outgoing request or response headers. Use them when your code accepts user-provided header names or values and you want the failure to happen before building the outgoing message.
import { validateHeaderName, validateHeaderValue } from 'node:http';
validateHeaderName(name);
validateHeaderValue(name, value);Node also validates when you call APIs such as response.setHeader() or http.request({ headers }). The explicit validation functions let you control where the failure happens. Invalid names throw TypeError with ERR_INVALID_HTTP_TOKEN. Invalid values can throw codes such as ERR_HTTP_INVALID_HEADER_VALUE or ERR_INVALID_CHAR.
Header count and header byte size are controlled separately. server.maxHeadersCount defaults to 2000 and limits how many received header pairs Node uses when it builds normalized and distinct header views. A value of 0 removes that count limit. maxHeaderSize limits total header bytes. One request can create count pressure with many tiny fields. Another can hit the byte limit with one very large field.
Keep these controls separate when debugging. Count limits affect field materialization. Byte limits affect parser input size. Validation functions check outbound field syntax. Duplicate joining changes the JavaScript object view.
The path is -
parse header bytes
-> enforce parser grammar and size limits
-> collect raw header pairs
-> create normalized and distinct views on demand
-> validate outgoing headers when sendingWhen a request fails before your handler, start with parser grammar and limits. When a header value looks different inside your handler, inspect materialization rules. When an outgoing response throws, inspect the validation path.
Body Bytes
The parser owns HTTP framing. JSON, form data, multipart upload, and application payload formats belong to later code. After the parser has identified body bytes, Node exposes those bytes through the IncomingMessage readable stream.
For a fixed-length request body, llhttp tracks the remaining byte count from Content-Length. Each body callback reports bytes that belong to the message body. Node pushes those bytes into the request stream. When the remaining count reaches zero, the parser can complete the message and prepare for another message on the same connection.
For chunked transfer coding, llhttp parses the chunk size, then treats exactly that many following bytes as chunk data. Node exposes only the body data bytes to your request stream. The chunk size lines and chunk delimiters stay inside the parser layer. Application code sees body bytes, not HTTP chunk syntax.
http.createServer((req, res) => {
req.on('data', chunk => console.log(chunk.length));
req.on('end', () => res.end('done\n'));
}).listen(3000);Each chunk here is a body chunk from Node's readable stream. Its size comes from buffering and parser delivery. It does not tell you the application-level payload format. For chunked transfer coding, these stream chunks also do not have to match HTTP chunk sizes.
Backpressure reaches the parser through stream state and socket reads. If the IncomingMessage readable side has buffered enough data, Node can stop reading more bytes from the socket until the stream consumer catches up. Fewer socket reads means fewer parser executions. The parser state stays parked at the current body position.
This creates a common server bug. A handler that ignores a request body can leave body bytes unread. Those bytes still belong to the current HTTP message. Until Node can finish or discard that body according to its lifecycle rules, the connection is a poor candidate for reuse. Subchapter 5 covers keep-alive and pooling consequences, but the parser is where the message remains incomplete.
message.complete tells you whether Node received and parsed the complete HTTP message. It becomes useful when a connection closes mid-body. A destroyed socket can make the request stream end through a connection failure path while the HTTP message remained incomplete.
This also explains why req exists before the whole request body exists. Headers are enough to create the request object and route the request. The body can keep arriving while your handler validates metadata, opens a file, or prepares an upstream call.
The parser's body callbacks report body spans owned by the HTTP message. A 10 MiB upload can arrive across many socket reads. Node can push many body chunks. A single parser execution can also contain enough bytes to finish a fixed-length body and continue into the next pipelined message. The parser tracks HTTP message positions. The stream layer tracks JavaScript consumption.
For a fixed-length request body, the path is -
headers complete
-> content length stored
-> body bytes pushed to IncomingMessage
-> remaining length decremented
-> message complete when remaining length reaches zeroFor chunked transfer coding, the path is -
headers complete
-> chunk size parsed
-> chunk bytes pushed to IncomingMessage
-> zero-size chunk
-> trailers parsed
-> message completeBoth paths can surface through the same req.on('data') API. The parser work underneath changes because the HTTP framing bytes change.
Unread bodies create recognizable symptoms. The handler might send a response early. The socket might stay busy while the client continues uploading. The connection might close instead of becoming reusable. A later keep-alive request might wait behind body cleanup. Those symptoms come from a message where headers were accepted but body state is still active.
For server code that intends to ignore a body, req.resume() is the simple discard path. It consumes and discards body bytes so the message can reach completion. For code that cares about limits, explicit body readers with byte caps belong in Subchapter 4. The parser's job ends at framed bytes. Application body policy starts after that.
Client response bodies have the same pressure pattern. A client response parser can parse headers and emit response, then body bytes flow through the response IncomingMessage. If client code leaves the response body unread, the socket stays tied to that response. Agent pooling and reuse suffer later.
Parse Errors and Limits
Malformed input reaches Node as a parser return code before it reaches your route code.
llhttp error codes use the HPE_* prefix. HPE_INVALID_METHOD means the parser rejected the method token. HPE_INVALID_HEADER_TOKEN means a header byte failed the header grammar. HPE_INVALID_CONSTANT, HPE_INVALID_VERSION, and other codes point to the parser state that rejected the input.
Read the code as the parser telling you where it was working. It is reporting the HTTP rule it was checking when the byte failed.
const server = http.createServer((req, res) => {
res.end('ok\n');
});
server.on('clientError', (err, socket) => {
console.error(err.code);
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});With a clientError listener, your code owns the socket outcome. Parser failures at that point happened before req and res construction. If you want to send an HTTP response, you have to write it directly to the socket. The listener also has to close or destroy the socket before it returns.
Node adds two fields that help during parser diagnostics. err.bytesParsed is the number of bytes Node may have parsed before the error. err.rawPacket is the current buffer involved in the failure path. Log carefully. Raw packets can contain credentials or payload data.
The missing req and res are the operational clue. Your normal response helpers, header setters, and framework middleware run only after successful parsing. A parser error lands before all of that. The socket is the only output path still available.
That also means clientError handlers should stay simple. Check whether the socket is writable. Write a minimal HTTP response when that makes sense. End or destroy the socket. Logging can include err.code, err.bytesParsed, peer address data, and a redacted packet sample. Full packet logs can leak secrets.
Malformed method input is a clean example -
GE T / HTTP/1.1
Host: example.testThe space inside the method token violates the request-line grammar. llhttp returns an HPE_* error. Node surfaces it through clientError. The request listener does not run because the parser rejected the start line before Node had a valid request object.
Header overflow takes another route. Header overflow means the received header section exceeded the configured header byte limit. In Node v24, the default maximum header size is 16 KiB. The process-wide value is visible as http.maxHeaderSize and can be configured with --max-http-header-size. A server can override it with the maxHeaderSize option. A client request can also set maxHeaderSize for response headers.
const server = http.createServer({
maxHeaderSize: 1024
}, (req, res) => {
res.end('ok\n');
});That server accepts at most 1024 bytes of request headers for each incoming request. If the header section exceeds the limit, the parser reports HPE_HEADER_OVERFLOW. Node's default server behavior sends 431 Request Header Fields Too Large for that code. A custom clientError listener can change the response, but it still has to close the socket path cleanly.
--max-http-header-size changes the default limit for the process -
node --max-http-header-size=32768 server.jsThat flag affects the default header byte ceiling. Per-server maxHeaderSize is the local override. Keep the units in bytes. A header field with non-ASCII characters on the wire still consumes bytes, and the parser limit counts bytes.
server.maxHeadersCount is a count limit for incoming header pairs. It defaults to 2000. Node uses that count when adding header lines to the request object's normalized views. A lower value caps how many pairs become visible through headers and headersDistinct. A value of 0 removes the count limit.
The outcomes are not the same. HPE_HEADER_OVERFLOW is tied to bytes. A large cookie, a huge bearer token, or a pile of forwarding metadata can hit it. server.maxHeadersCount is tied to field count and request-object materialization. Many tiny X-Thing-N headers can create count pressure while staying under the byte ceiling.
Timeouts sit beside these limits. server.headersTimeout controls how long Node waits to receive a complete header section. maxHeaderSize controls how large that section can become. server.maxHeadersCount controls how many fields Node carries into its header views. One slow client can hit the timeout. One bulky client can hit the byte limit. One noisy client can hit the count limit.
Headers timeout and header byte size can stop the request before the handler. Header count pressure can still produce a request object, with capped normalized and distinct header views. Inspect rawHeaders during diagnostics when the received pair list is relevant.
requireHostHeader controls one HTTP/1.1 requirement. In Node v24, http.createServer() defaults it to true. An HTTP/1.1 request missing Host gets a 400 Bad Request response from the server path. Setting it to false loosens that check for compatibility with peers that send non-conforming requests.
const server = http.createServer({
requireHostHeader: true
}, handler);That option runs at the parser and server layer. It decides whether Node accepts a structurally complete HTTP/1.1 request that lacks required host metadata. Routing code should receive requests that already passed that check.
Clients have their own version of this problem. http.request() can fail from DNS, TCP, TLS for HTTPS, or HTTP parse errors. On plain node:http, an invalid response header from an upstream server becomes an error on the ClientRequest. The parser rejected bytes from the server response before Node could deliver a valid IncomingMessage.
That ordering affects retry logic. A parse error means the peer sent response bytes outside Node's accepted grammar or limits. Retrying blindly can repeat the same parser failure. Treat parser errors as protocol compatibility or upstream correctness problems until logs show another cause. Socket reuse can add another wrinkle, but the first classification remains the same - the parser rejected the response grammar or response limits.
For production logs, capture -
error code
bytes parsed
server or client path
local and remote socket address
header size and count settings
leniency settingThat is enough to separate "peer closed the TCP connection" from "parser rejected a header" from "header bytes exceeded the configured ceiling."
A helpful debugging trick is to group failures by the first missing object.
If req is absent, the parser failed before request construction or the socket failed before a message existed. Check clientError, socket errors, err.code, bytesParsed, and header-size settings.
If there is a req and req.complete is false, the message started but completion failed. Check body framing, socket close timing, declared length, and whether the client stopped sending bytes.
If there is a full req and the route throws, the parser already did its job. The failure belongs to user code or application body parsing.
That grouping keeps parser errors from turning into framework debugging sessions. A method token rejected before request construction belongs upstream of routes. A header section that exceeded maxHeaderSize belongs upstream of body parsers. An upstream server that sends invalid response syntax on every attempt belongs upstream of retry loops.
Limits can also disagree across deployment hops. A reverse proxy may allow larger headers than Node. Node may allow larger headers than an upstream service. A client might send a request that passes one hop and fails at the next. When diagnosing a 400 or 431, record the configured header byte limit at every HTTP hop that parses the message.
The same applies to requireHostHeader. A proxy might add, normalize, or reject Host. Node's default server policy rejects HTTP/1.1 requests that reach it without Host. If the request came through an intermediary, the missing header may reflect proxy behavior rather than the original client bytes. Chapter 10's proxy subchapter covers forwarding policy. This layer covers Node enforcing the server option before routing.
Lenient Parsing
Strict parsing is the normal behavior. Node's default parser rejects malformed HTTP syntax instead of guessing what the peer meant.
insecureHTTPParser turns on llhttp leniency flags for a server or client request. The process flag --insecure-http-parser applies the same idea globally. Parser leniency means the parser accepts certain inputs that strict parsing rejects - invalid header values, invalid HTTP versions, selected framing combinations, bare line feeds, and some chunk formatting deviations.
const server = http.createServer({
insecureHTTPParser: true
}, (req, res) => {
res.end('accepted\n');
});That option is a compatibility escape hatch. It expands the HTTP grammar Node accepts. It also increases the chance that your Node process and another HTTP hop will interpret the same bytes in different ways. Request smuggling and proxy normalization belong to later security and proxy chapters. The parser-level fact is simple - lenient parsing accepts more byte sequences as HTTP.
Use it for a known peer, a measured compatibility problem, and a controlled route or service. Keep it out of default server setup.
Leniency also affects client code. If an upstream server emits response syntax that Node's strict parser rejects, http.request({ insecureHTTPParser: true }) can accept that response. The tradeoff is the same. You changed the protocol grammar accepted by the client.
Parser leniency runs before application validation. A lenient parser can still produce req.headers, req.url, and a body stream. Your application can still reject the request later. The parser decision happens first, while Node is deciding whether the incoming bytes count as HTTP at all.
Leniency is also separate from duplicate joining. joinDuplicateHeaders changes the JavaScript view of accepted headers. insecureHTTPParser changes which byte sequences the parser accepts before those views exist. One is an object-view option. The other is a grammar option.
Treat grammar leniency as a deployment decision. If a reverse proxy, a Node server, and an upstream service disagree about accepted syntax, the same bytes can produce different message interpretations at different hops. Chapter 25 covers the attack classes. For now, the parser-level lesson is enough - a lenient parser changes which messages can enter your application.
For client code, prefer a narrow wrapper over the process flag. If one upstream emits a bad response version or header value, set insecureHTTPParser on that one request path and leave the rest of the process strict. That keeps the compatibility exception visible in code review.
There is one more operational trap. Leniency can make a test pass by accepting bad input. That proves only that the configured parser accepted the input. Keep test fixtures explicit about strict or lenient mode so later readers know which parser behavior the test is exercising.
Trailers
Trailers are header fields that appear after the message body. In HTTP/1.1, they most commonly appear with chunked transfer coding. The parser exposes them after it has parsed the body end and then parsed the trailer section.
That timing is the part to remember. message.trailers, message.rawTrailers, and message.trailersDistinct are populated at the 'end' event. Before the body completes, those fields are empty or incomplete.
http.createServer((req, res) => {
req.resume();
req.on('end', () => {
console.log(req.rawTrailers);
console.log(req.trailersDistinct);
res.end('ok\n');
});
});rawTrailers mirrors rawHeaders. It is a single array of received trailer names and values, preserving received casing and pair order. trailers gives the normalized object view. trailersDistinct gives lower-cased keys with array values and separate received values.
The parser has to reach message completion before those views are reliable. If a body is abandoned midway, the trailer state stays incomplete.
Trailers can be useful in some protocol designs, but most application request handling should avoid depending on them for routing or authorization. The body has already arrived by the time trailers become available.
The parser path is -
body bytes
-> zero-size chunk
-> trailer field and value pairs
-> blank line
-> message completeAt message completion, Node can populate the trailer views. Before then, a handler has body bytes and ordinary headers, while post-body metadata is still pending.
Outgoing trailers use a separate API on the outgoing message path. response.addTrailers() queues trailers for a chunked response. On the receiving side, Node's parser turns received trailer fields into message.trailers, message.rawTrailers, and message.trailersDistinct. The send path and receive path meet on the wire format, then return to Node's outgoing and incoming APIs.
A simple rule follows from the timing. If you need metadata for routing, authentication, content limits, or early rejection, trailers arrive too late. If you need post-body metadata for an integrity check or a backend protocol convention, read it after 'end' and handle the missing-trailer case directly.
Idle Parsers
An idle HTTP parser is a parser object that Node keeps available after active message parsing ends. Parser allocation has a cost. Reusing parser objects can reduce repeated allocation work in busy HTTP processes.
http.setMaxIdleHTTPParsers() controls how many idle parser objects Node keeps -
import http from 'node:http';
http.setMaxIdleHTTPParsers(500);The default is 1000 in Node v24. Lowering the number can reduce parser memory retained by an application with many HTTP bursts. Raising it can reduce allocation churn in applications that create and release many HTTP parser instances.
Parser reuse is separate from HTTP connection pooling. A connection pool owns sockets. An idle parser pool owns parser objects. A keep-alive socket may carry multiple HTTP messages over time. An idle parser may later attach to work on another socket. They are separate resources with separate knobs.
Parser reset is what makes parser reuse possible. After a parser finishes a message and Node detaches message state, the parser can return to its start state with the same configuration - request or response mode, callbacks, and leniency flags. Then Node can keep it idle or attach it to another parsing path.
Most applications leave this setting alone. Reach for it when memory profiles show retained HTTP parser objects or allocation profiles show parser churn. It is a runtime resource knob, not a request behavior knob.
A low idle-parser setting can increase parser allocation work during bursts. A high setting can retain more memory after bursts. The right value depends on traffic pattern and memory profile, separate from request semantics.
Parser retention also sits outside agent tuning. http.Agent controls client-side socket reuse. Server keep-alive options control incoming socket lifetime. http.setMaxIdleHTTPParsers() controls parser object retention. If a service has too many open sockets, parser retention is the wrong fix. If a service keeps too much parser memory after traffic drops, socket pool settings are the wrong fix.
The parser pool exists because parser setup has state - callbacks, mode, configuration, and native memory. Reuse keeps that setup available. Reset clears per-message state. Idle retention decides how many reset parser objects Node keeps around.
Upgrade Handoff
An Upgrade header asks the server to switch protocols after the HTTP/1.1 request. The parser still has to parse the HTTP request first. Only after the request line and header section are valid can Node decide whether to hand off the socket.
A typical upgrade request starts like this -
GET /chat HTTP/1.1
Host: example.test
Connection: Upgrade
Upgrade: websocketThe parser reads this as an HTTP request with upgrade metadata. If the server accepts the upgrade, Node emits the upgrade event with three values - req, socket, and head.
const server = http.createServer({
shouldUpgradeCallback: req => req.url === '/chat'
});
server.on('upgrade', (req, socket, head) => {
socket.destroy();
});shouldUpgradeCallback is a Node v24 server option that decides which upgrade attempts are accepted. It receives the incoming request and returns a boolean. Accepted upgrades emit upgrade. Rejected upgrades continue through the ordinary request event path.
The default decision is tied to whether the server has an upgrade listener. In Node v24.9 and newer, if an upgrade is accepted and the server lacks an upgrade listener, Node destroys the socket. That avoids leaving an accepted upgraded socket with no owner.
After upgrade fires, the HTTP parser is done with that socket. Node removes the normal HTTP data handling from the socket. The code handling the upgraded protocol owns the raw socket bytes. head contains bytes already read beyond the HTTP headers that belong to the upgraded protocol.
WebSocket starts on the far side of that handoff. The HTTP parser identifies the request, validates the HTTP grammar, and hands over the socket. Frame parsing, ping and pong, close frames, masking, and reconnect behavior belong to the realtime chapter.
The practical line is easy to remember. Before upgrade, Node is enforcing HTTP/1.1 grammar and building HTTP objects. After upgrade, your upgrade handler or protocol library owns the socket and every byte that follows.
The head buffer is easy to mishandle. The socket may have delivered bytes beyond the HTTP headers in the same read that completed the upgrade request. The parser consumes the HTTP portion and leaves the extra bytes for the upgraded protocol. Node passes those extra bytes as head. A protocol handler that ignores head can drop the first bytes of the upgraded stream.
Rejected upgrades stay in HTTP request and response land. Your normal request handler can return a status code and body. Accepted upgrades leave that path. The socket becomes a raw stream owned by the upgrade handler. Response helpers, HTTP timeouts, body parsing, and keep-alive reuse no longer describe the bytes that follow.
Parser pause is the mechanism underneath that handoff. llhttp reaches the upgrade point and stops treating following bytes as HTTP/1.1. Node then transfers ownership to the upgrade event. The parser has completed the HTTP part of the exchange. The next protocol starts with the socket and the head bytes.