HTTP/1.1 Wire Format and Semantics
GET /users?id=1 HTTP/1.1 is already HTTP.
That line is raw socket data and a structured message at the same time. TCP hands Node a stream of ordered bytes. HTTP is the grammar that reads those bytes and decides they form a request or a response.
Chapter 9 stopped at readable bytes on a connected socket. This chapter picks up one layer higher. The HTTP work starts the moment those bytes get read as a message, with a start line, a header section, an optional body, and a role of request or response.
This is a small HTTP request, with its visible ASCII bytes decoded.
GET /users?id=1 HTTP/1.1
Host: api.example.test
Accept: application/jsonThat blank line at the bottom is structural. On the wire, every displayed line ends with \r\n, the carriage-return byte 0x0d followed by the line-feed byte 0x0a.
The empty line is one more \r\n. It marks the end of the header section, and any body bytes start right after it.
Most terminal tools hide \r\n when they print HTTP messages. The output reads more cleanly that way, though it also buries the spot where the headers actually end. When you debug raw HTTP, the four bytes to look for are these -
0d 0a 0d 0aThose four bytes mark the split between the header section and whatever bytes come after.
Now the same request with every CRLF spelled out -
GET /users?id=1 HTTP/1.1\r\n
Host: api.example.test\r\n
Accept: application/json\r\n
\r\nEverything before the first \r\n is the request line. Header fields come next, one per line. The empty line ends the metadata, and body bytes follow only when HTTP framing says a body exists.
A client and a server agree on what these bytes mean, and that agreement splits into two parts. The semantics are the meanings behind methods, status codes, field names, and message content. The wire format is the byte syntax that carries those meanings across a connection. This chapter stays on HTTP/1.1, where the start lines and header fields are text, the line endings are CRLF, and the body framing sits on top of a reliable TCP stream.
The parser reads the start line first. That line says whether the message is a request or a response. A request line carries the method, the target, and the HTTP version. A response line carries the HTTP version, the status code, and the reason phrase.
The header section comes next. Headers carry the host, content metadata, body framing, content preferences, and connection options.
After the empty line, the parser decides whether body bytes follow, and if they do, how many of them belong to this message. That decision has to land correctly before the same connection can carry another HTTP message.
TCP only promises ordered bytes. The message structure on top of them comes from the HTTP parser.
A single socket 'data' event can hand you half a request line, one full request, two complete requests, or the tail of one body followed by the start of the next request. Where one message ends and the next begins is decided by the parser, never by the way the socket happened to chunk the bytes.
What the parser has worked out is what Node can safely expose. With the request line and header section parsed, Node can build an IncomingMessage. With the body's ending known, Node can mark the current message complete and let the same connection carry the next one. When the parser cannot find where the body ends, the connection has no safe way forward and stays on the current message.
The running order is simple -
TCP bytes
-> start line
-> header section
-> empty line
-> optional body bytes
-> next message or closeEvery Node HTTP API on top of this builds on that order. req.method, req.url, req.headers, res.statusCode, keep-alive behavior, parser errors, proxy forwarding, streaming bodies, all of it traces back to those pieces.
The bytes arrive a little at a time. One socket read might stop after GET /use. The next finishes the request line and brings a few headers. A later read delivers body bytes. HTTP/1.1 parsing has to be stateful for exactly this reason, since the parser needs to remember where it left off between reads.
A parser moves through states such as these -
reading start line
reading header fields
reading fixed-length body
reading chunks
message completeThose are parser states. Your JavaScript handler phases run alongside them as a separate thing. The handler can start while the request body is still arriving, because the metadata lands first and the body keeps coming through the request stream after that.
Partial reads are the normal case. A socket might deliver one request in fragments -
GET /use
rs?id=1 HTTP/1.1\r\nHost:
api.example.test\r\n\r\nAfter the first fragment, the request line is still incomplete, because its CRLF has not arrived. After the second, the parser holds the full request line plus part of the Host field. After the third, it has the whole header section and can finish a request that carries no body.
So the way JavaScript chunks the bytes tells you nothing about where an HTTP message begins or ends. That gap is what makes string-splitting individual socket chunks fall apart under real traffic. A socket chunk only reflects how bytes happened to arrive from the transport. HTTP syntax fixes where the request line ends, where the headers end, and where the body ends. Node's HTTP parser sits between the transport and your code, turning byte fragments into protocol events.
A 'data' event tells you nothing about where a message starts or ends. One event can hold half a request line, several full requests, or the tail of one body followed by the start of the next message. So don't split a single socket chunk on \r\n\r\n and treat the pieces as requests. The HTTP framing rules, read across the whole accumulated byte stream, decide where one message stops and the next begins.
HTTP Starts After TCP
A connected socket gives you the transport and nothing more. The message model on top of it comes from HTTP.
One HTTP message is one complete protocol unit. In HTTP/1.1 that unit is a start line, zero or more header fields, an empty line, and an optional body, and it is either a request from a client or a response from a server.
Calling the body optional means it exists only when HTTP semantics and framing call for one. The blank line just ends the header section and tells the parser where the metadata stops. Body length is settled separately, by method rules, status rules, and framing fields such as Content-Length or Transfer-Encoding.
A body can be empty for more than one reason. Some responses carry zero body bytes by rule. Some let the connection close act as the body's end. Chunked transfer coding marks the end with a final zero-size chunk.
At the net.Socket layer Node sees only bytes. The HTTP layer reads them in order, taking the start line first, then header fields up to the empty line, then applying the body-length rules.
Node's HTTP objects and the llhttp parser come in later subchapters. The model you need right now is short. Your JavaScript code only receives an HTTP request once the lower layers have parsed enough bytes to identify one.
HTTP/1.1 layers connection behavior on top of TCP as well. One TCP connection can carry several request and response exchanges back to back, but the parser only moves to the next exchange once the current one is finished.
Framing fields are what make that reuse possible. A correct Content-Length lets the receiver count out exact body bytes. Chunked transfer coding supplies explicit chunk sizes and a final marker. Connection-close framing takes the rest of the connection as the body.
The protocol syntax works on bytes. Showing the start line and fields as strings is handy for reading, but the parser deals in bytes first, and it has to handle delimiters, invalid bytes, and body content before any string parsing starts.
Field values get decoded and interpreted afterward. Body bytes might be JSON, text, compressed data, image data, or nothing at all. The parser's first job is just the HTTP structure.
This is the reason HTTP parsing sits underneath ordinary application code. A route handler can work out whether /users?id=1 maps to a route. A body parser can work out whether the body is valid JSON. Neither runs until the HTTP parser has pinned down the method token, the request target, the header lines, and the body framing.
This byte-level structure is also what lets raw net.Socket examples speak HTTP with plain strings. HTTP/1.1 opens with text-like control bytes, so a minimal client can write a valid request by sending the request line, the Host header, and the empty line -
socket.write([
'GET / HTTP/1.1',
'Host: example.com',
'',
'',
].join('\r\n'));That snippet is good for inspection. A real production client needs far more than this. It shows the header ending plainly, and the two trailing empty strings produce the CRLF after the Host line and the CRLF for the empty line.
Raw captures help by keeping the structure visible. When a Node HTTP issue stops making sense, write the received bytes down in this order -
start line
header field line
header field line
empty line
body bytes, if anyIf that layout is unclear, fix the parsing or framing before anything else. Routing, middleware, handlers, and JSON parsing all sit on top of it.
Request Message Structure
Every HTTP request opens with a request line -
GET /users?id=1 HTTP/1.1
Host: api.example.testThe request line has three pieces - method, request target, and HTTP version. In this request, GET is the method, /users?id=1 is the request target, and HTTP/1.1 is the version.
The spaces are part of the syntax. In the normal request line, one space separates the method from the target, and another separates the target from the version -
method SP request-target SP HTTP-version CRLFSP is the space byte. CRLF is the two-byte line ending. A parser may allow a few historical edge cases, but your working model should stay strict - method, target, version, line ending, then headers.
The HTTP method is the token that declares the protocol action. GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, and CONNECT are the ones you see most. At this layer the parser treats the method as pure syntax, and the application meaning attaches afterward.
A little of that meaning helps here. GET asks for a representation. HEAD asks for the response metadata a GET would have produced, with no body bytes. POST sends content for the target resource to process. That covers what this chapter needs from methods. Resource modeling, idempotency, and API contracts are their own topic.
The request target says what the request is aimed at inside the HTTP interaction. For ordinary requests to an origin server, it is usually a path plus an optional query string -
GET /users?id=1 HTTP/1.1
Host: api.example.testThe target is /users?id=1. The Host header supplies the authority, which is the host and optional port. Together, the receiver can reconstruct the target URI for routing and validation.
Framework routing is a higher-level concern. At the wire level, the request line carries the path and query in the common form, and Host carries the authority.
The query string is part of the request target bytes. HTTP does not split query parameters apart for you at this stage. Node or your own code can feed the target into URL and URLSearchParams afterward. On the wire, the request line still carries one target token.
The version token says which HTTP/1.x grammar the sender used. HTTP/1.1 is the case-sensitive name HTTP, a slash, then major and minor digits. The version drives connection defaults and required fields. A Node server still parses the raw bytes before it can hand any useful value to your handler.
That same token doubles as a debugging clue. A capture that starts with PRI * HTTP/2.0 is the HTTP/2 connection preface, which Chapter 11 takes apart. A request that starts with GET / HTTP/1.0 shifts the connection defaults and the required fields. Anything that does not fit request-line syntax gets rejected by Node's HTTP parser before your routing code runs.
After the request line, header fields carry metadata -
POST /users HTTP/1.1
Host: api.example.test
Content-Type: application/json
Content-Length: 17The empty line after these fields ends the header section. The Content-Length: 17 field says that 17 body bytes follow. The body stays separate from the header section, even when the body contains text that looks like a header.
Now add the body -
POST /users HTTP/1.1
Host: api.example.test
Content-Type: application/json
Content-Length: 17
{"name":"Ada"}The JSON text is easy to miscount by eye. Body length is measured in bytes, not by how the text looks in a code block. This example says 17, so a receiver following the field waits for 17 bytes. If fewer arrive and the sender stops, the message is incomplete. If extra bytes arrive, the receiver keeps treating them as body until the declared count is satisfied.
That mismatch is the parser-sensitive part. Your application code wants objects and strings, while the wire offers bytes and declared lengths, and the parser has to settle the difference before your handler ever sees a request stream.
Use a matching length -
POST /users HTTP/1.1
Host: api.example.test
Content-Type: application/json
Content-Length: 14
{"name":"Ada"}Now the body ends after the closing } byte. If another request starts immediately after it on the same connection, the next byte belongs to the next start line.
Request meaning begins with the method and target, while body handling begins with framing. A GET with no body framing fields has zero body bytes. A POST with Content-Length: 0 has zero body bytes too. The method name by itself never tells the parser enough to read a body.
You can see this directly in Node. req.method and req.url are available once the start line is parsed. The request body is a readable stream that can arrive later. One request line, one header section, then the body as flow-controlled data after the metadata.
When a request has a body, the parser can expose the headers before that body finishes. Server code gets to inspect the method, target, and headers before it decides whether to keep reading. If the handler accepts, it reads bytes from the request stream. If the handler rejects early, the server still has to handle whatever body bytes are left on the connection.
A request with a body has two visible moments -
metadata complete
body stream still flowingThose two moments let a server turn away a large upload after reading only the headers, or take the request and stream the body through backpressure-aware code. Either way it stays one HTTP message.
Request Target Forms
HTTP/1.1 defines four request target forms. Almost all backend code deals with a single one of them, origin-form.
Origin-form is the path and optional query string sent to an origin server -
GET /users?id=1 HTTP/1.1
Host: api.example.testThe request target is /users?id=1. The authority comes from Host. Node HTTP servers commonly expose that target as the URL-like value you later parse with URL or routing code.
Origin-form also uses / when the target URI has an empty path -
GET / HTTP/1.1
Host: api.example.testThat root slash is still the request target. Route decisions happen later. The slash is the path component the client placed into the request line.
Absolute-form carries a full URI in the request line -
GET http://api.example.test/users?id=1 HTTP/1.1
Host: api.example.testForward proxies use this form because the next recipient needs the full destination written into the request line. Reverse proxy behavior and forwarding rules show up further along in this chapter. Read the bytes literally here. The target holds the scheme, the authority, the path, and the query.
Absolute-form can throw you off in server logs, since backend handlers usually expect a bare path. At the HTTP layer, both of these are valid request targets -
/users?id=1
http://api.example.test/users?id=1Which one a receiver expects depends on its role. An origin server usually expects origin-form, and a proxy-facing hop can receive absolute-form.
Authority-form is the authority component on its own -
CONNECT api.example.test:443 HTTP/1.1
Host: api.example.test:443CONNECT uses authority-form because the host and optional port sit in the request-target position. Proxy tunnel behavior is covered in the proxy subchapter. The syntax is all you need right now, a host plus optional port standing where the target normally stands.
Asterisk-form is the single * target -
OPTIONS * HTTP/1.1
Host: api.example.testThat form addresses the server as a whole for the OPTIONS method. Ordinary application routes rarely see it because route tables usually focus on paths.
These four forms clear up a confusion that shows up in raw logs. A Node server behind ordinary infrastructure receives origin-form. Put a proxy in front and a component might see absolute-form instead, a tunnel request arrives as authority-form, and a server-wide OPTIONS request arrives as asterisk-form.
The first token is still the method and the second token is still the request target. What the form changes is how the receiver reads that target.
In HTTP/1.1 the Host field is part of that reading. A single server can receive many hostnames on one local address and port. The TCP connection only knows the local and remote socket addresses, so the requested authority has to come from the Host header at the HTTP level.
Virtual hosting, routing, and proxy policy all lean on that field higher up. At the wire-format layer its job is simpler, since an HTTP/1.1 request carries authority metadata in the header section.
When you debug raw Node traffic, the form on the request line hints at where the request came from. Direct clients send origin-form, a client pointed at a forward proxy can send absolute-form, and the CONNECT and asterisk cases mark a tunnel setup or a server-wide OPTIONS probe.
If the form on a request is not the one you expected, trace how the request reached the process before you touch any application routes.
Rebuilding the full target takes more than the request line. With origin-form, the request line supplies the path and query, Host supplies the authority, and the connection context supplies the scheme depending on whether the connection is plain HTTP or wrapped in TLS. Node assembles the target URI from those protocol bytes plus the connection context, then passes it up for routing and policy.
Response Message Structure
An HTTP response starts with a status line -
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 11
{"ok":true}The status line has three pieces - HTTP version, status code, and reason phrase. Here, HTTP/1.1 is the version, 200 is the status code, and OK is the reason phrase.
The status code carries the machine-readable result, always as three decimal digits. The first digit gives the class - 1xx informational, 2xx success, 3xx redirection, 4xx client error, and 5xx server error.
Chapter 12 covers API contract decisions around which status code to pick. At the wire level, the code sits in the status line and controls response behavior, including body rules for a few classes and codes.
Status classes are broad protocol categories -
1xx informational
2xx successful final response
3xx redirection final response
4xx client error final response
5xx server error final responseThe class is still useful when a client sees an unfamiliar exact code. A client that lacks special handling for 418, for example, can still place it in the 4xx class. Application policy sits above this layer.
After the status code comes the reason phrase, the human-readable text in HTTP/1.1. It can be empty, or it can differ from the usual wording. Clients should drive behavior off the numeric code. The phrase still shows up in raw responses and logs, but the protocol follows the number.
These two status lines carry the same machine-readable result -
HTTP/1.1 200 OK
HTTP/1.1 200 FineThe code is 200 in both. The phrase changes. A parser can record the phrase, but client behavior should follow the code.
A response can carry header fields and a body -
HTTP/1.1 404 Not Found
Content-Type: text/plain
Content-Length: 7
missingThe status line on its own says nothing about body length. Here Content-Length provides it. A handful of status codes force zero body bytes no matter what the framing says, other responses stream with chunked transfer coding, and some end the body by closing the connection. The parser weighs the status semantics together with the framing fields to find where the body ends.
Responses also follow the order of the connection. Requests go out from the client and come back as responses in the same sequence. HTTP/1.1 leans on that ordering to tie a response to its request, because the wire format has no per-response request ID.
If a client sends multiple requests on one connection, final responses are matched in the same order the requests were sent. Any 1xx responses belong to the request currently waiting for its final response.
Because the matching depends on order, one bad frame can break more than a single message. A client that reads too few body bytes will mistake the leftover bytes for the next response. One that reads too many consumes bytes belonging to the message after it. Either error makes the following responses stop lining up with their requests.
In a Node client or server, the visible objects arrive a little later. The raw response is still a status line, a header section, an empty line, and body bytes. Once you can read those four pieces in a capture, Node's higher-level events are easier to place.
The same parsing order is why a client can have the headers before the body. It learns the status code and header fields first, then takes the body over time. A large download, a chunked response, and an empty response all begin with the same status line and headers. What comes after those headers is decided by the body framing.
Header Fields
A header field is one metadata line inside the header section -
Content-Type: application/jsonContent-Type is the field name. application/json is the field value. The colon separates them on the wire. Optional whitespace can appear after the colon. Field names are case-insensitive, so content-type and Content-Type refer to the same field. Field values have their own syntax, depending on the field.
Many APIs normalize field names, but the raw wire data can preserve the sender's casing -
content-type: application/json
CONTENT-LENGTH: 14
Host: api.example.testThose names still match their case-insensitive field definitions. Node later exposes normalized header objects and raw header arrays. The parser has to keep enough information for both views.
The header section runs from the start line down to the empty line, holding the header fields in between -
GET /users HTTP/1.1
Host: api.example.test
Accept: application/json
User-Agent: curl/8.0Three header fields appear in that section. The empty line ends it. After the empty line, a later body line remains body data even if it contains a colon.
A field might appear once, or repeat, and a repeated field might combine with commas or need special handling. How parsers and frameworks treat repetition is a later topic, partly because Node exposes both normalized headers and the raw header order. On the wire, a repeated field is nothing more than several field lines sharing one field name.
Repeated fields are visible at the byte level -
Accept: application/json
Accept: text/plain
Host: api.example.testThe field definition decides whether the values combine, and how. Accept values fold into a list. Other fields have rules that make blind joining unsafe, which is the reason raw header order stays useful when you debug.
A header field name is a protocol token that picks out a metadata slot with registered or application-defined meaning. The value is a byte sequence held to the HTTP field-value grammar, then interpreted by whatever that field's definition says.
Content-Length: 14 carries numeric framing meaning. Content-Type: application/json names the media type of the content in the message. Accept: application/json tells the server which response media types the client can handle. Content negotiation policy is an API-design question, while the Accept field on its own is just request metadata.
Content-Type describes the content being sent in the current message -
Content-Type: application/jsonIt labels the content. A JSON parser still has to read body bytes and parse them after HTTP framing.
Accept describes response types the client can process -
Accept: application/jsonThe server may use it when choosing a response representation. Chapter 12 covers that negotiation policy. The wire field is a client preference line inside the request header section.
Host needs special attention in HTTP/1.1 -
GET /health HTTP/1.1
Host: api.example.testThe request line says /health. The Host field says api.example.test. A server can use both to identify the target URI. With a non-default port, the field can include it -
GET /health HTTP/1.1
Host: api.example.test:8080That value lives at the HTTP layer rather than the TCP one. The TCP connection might land on 10.0.2.15:3000 after a load balancer or a local port mapping. The Host field still carries the authority the client wrote into the request.
Trust around these fields is a problem for the proxy and deployment chapters. The rule at the wire is plain, an HTTP/1.1 request needs host authority metadata.
Header fields take part in connection control too. Connection: close changes what happens after the current response. Other connection options flag fields meant only for the current connection. Forwarding is a proxy concern, yet a receiver still has to parse Connection before it carries any fields onward.
One working rule comes out of all this. Parse the header section as HTTP before you parse the body as application data. JSON parsing, decompression, and multipart parsing all run after framing. Until the empty line and the body framing are known, the receiver is still doing protocol work.
Header size and syntax errors live at this layer too. A field line with an invalid name, an oversized header section, or malformed whitespace can fail before any application route runs. The exact error surfaces come up in the Node parser chapters. The thing to get right now is placement, since bad header bytes are protocol bytes.
Header parsing has several layers -
HTTP parser field line endings
HTTP parser field names and values
field definition value meaning
application policy decisionThe field definition is what turns a value into something specific, a body length for Content-Length, a media type label for Content-Type, a client preference list for Accept, a target authority for Host. Treat every header value as a generic string and you throw that meaning away too early.
Body Length and Framing
The body is the payload, the bytes that come after the header section once the message rules say a body exists. It is the part your application code is usually after, and HTTP/1.1 forces the receiver to work out exactly where it ends.
The body is a byte sequence. It can be empty, or text, or JSON, or compressed data. It can even contain bytes that look exactly like \r\n\r\n. Once framing has placed bytes inside the body, the HTTP parser treats them as body bytes.
There are four common cases -
zero body bytes by rule
Content-Length gives exact byte count
Transfer-Encoding: chunked gives chunks
connection close ends the response bodyThe parser chooses among those cases using the start line, response status, and framing fields.
The decision path is ordered -
status and method body rules
Transfer-Encoding
Content-Length
request default: zero bytes
response default: read until closeThat fixed order is what lets the parser resolve conflicting framing. The receiver has to settle on one rule for the body end before it can pass any body bytes up to application code.
The same idea fits in a small framing table -
GET carrying zero body fields body ends at the empty line
POST with Content-Length: 14 body ends after 14 bytes
response with chunked body ends at the zero chunk
response with close delimiter body ends when the connection closesMost framing bugs trace back to one of those four cases. A body parser that gets fewer bytes than Content-Length is holding an incomplete message. A response reader that expects a status line while it is still inside chunk data has lost sync. A server that leaves request body bytes unread has to consume them, discard them, or close, before it can parse the next request on that connection.
Several kinds of message carry no body at all. Informational responses have none. 204 No Content and 304 Not Modified have none. A response to HEAD carries headers as though a body could follow, yet the real body length is zero. A request with no body framing fields has none either. In each of these, the receiver moves on to the next message right after the header section.
A zero-body response can still carry useful headers -
HTTP/1.1 204 No Content
Date: Wed, 10 Jun 2026 12:00:00 GMTThe message completes at the empty line. A client waiting for body bytes here is already reading the protocol incorrectly.
Content-Length is the simplest explicit framing field -
POST /users HTTP/1.1
Host: api.example.test
Content-Length: 14
{"name":"Ada"}The field value is a decimal byte count. The receiver reads exactly that many body bytes after the empty line.
If fewer bytes arrive before the connection closes, the message is incomplete. If more arrive, the surplus falls outside the current body. That surplus could be the next request on the same connection, or it could be invalid leftover data, and the parser tells them apart from the connection state.
The count is measured in transferred body bytes rather than JavaScript string characters. UTF-8 can spend more than one byte on a single visible character. Binary content can hold any byte value, including bytes that print as line breaks. Whatever parses the body, for JSON, text, form data, or another media type, runs only after HTTP has delivered the body stream.
Multi-byte text makes this visible -
Buffer.byteLength('{"snow":"\u2603"}');The source stays ASCII, but JavaScript turns \u2603 into one Unicode code point before Buffer.byteLength() counts UTF-8 bytes. Content-Length uses the byte count.
Content-Length counts the encoded body in bytes. A byte count and a JavaScript string length are two different numbers. '\u2603'.length is 1, but that character takes 3 UTF-8 bytes on the wire. Set the length from String.length and you undercount any non-ASCII body, which cuts the message short and shifts every byte after it into the wrong place. Get the number from Buffer.byteLength(body) or from a real Buffer.
Transfer-Encoding names the transfer codings applied to the body for transport. In HTTP/1.1 the common transfer coding is chunked, because it lets a sender stream a body before it knows the total length.
Chunked is transfer framing, a statement about carriage rather than content. Decode the chunks and the body underneath might still be JSON, text, or binary data. Content-Type is the field that labels that decoded content, while Transfer-Encoding only says how the body travels across this HTTP/1.1 connection.
A chunked response opens with a header section, then the chunks -
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/plain
5
hello
0The display hides the CRLF bytes around each chunk. With every byte shown, the chunk body is this -
5\r\n
hello\r\n
0\r\n
\r\n5 is hexadecimal for five body bytes. The receiver reads five bytes of chunk data, then the following CRLF. 0 is the terminating chunk. After that zero chunk, optional trailer fields can appear, then a final empty line ends the chunked body.
The chunk size is hexadecimal, so a means ten bytes -
a\r\n
0123456789\r\n
0\r\n
\r\nThe chunk data length is ten bytes. The chunk-size line and CRLF delimiters stay outside the decoded message body.
Trailer fields are header-field metadata that arrive after a chunked body. They exist for values you only know once the content has streamed. They are late HTTP metadata, kept separate from the initial header section. Application code rarely touches them, but the parser still has to know where they can show up.
A chunked body with one trailer field adds it after the zero chunk -
5\r\n
hello\r\n
0\r\n
Digest: sha-256=...\r\n
\r\nThe trailer field arrives after the terminating chunk size. It is still protocol metadata. The application body remains hello.
Chunked transfer coding gives the connection a clear return point for the next message -
chunk size
chunk data
chunk size
chunk data
zero chunk
optional trailer fields
empty line
next response or closeThis is what makes chunked bodies and connection reuse fit together. The sender streams content without precomputing Content-Length. The receiver still finds the end of the body and returns to start-line parsing for the next message.
Conflicting framing fields are a parser-layer problem. A message that carries both Transfer-Encoding: chunked and Content-Length gives two body-length signals. HTTP/1.1 recipients treat transfer coding as the framing authority, and intermediaries carry strict obligations around stripping conflicting length metadata before they forward.
Request smuggling gets its full treatment in the security chapters. The rule that applies here is narrower. A receiver has to reject, normalize, or otherwise resolve ambiguous framing before application code ever sees a clean request.
Multiple Content-Length field lines create another parser decision. Matching values can be reduced to one value by some recipients. Conflicting values make the message ambiguous -
POST /a HTTP/1.1
Host: api.example.test
Content-Length: 5
Content-Length: 7
hello!!The message claims two lengths. A receiver that trusts the first reads hello as the body. One that trusts the second reads hello!!. HTTP stacks treat that as a protocol problem, because every downstream recipient has to agree on where the body ends. Application code should get one clean reading, or an error.
A message that carries both Transfer-Encoding: chunked and Content-Length, or two Content-Length lines with different values, is ambiguous. When a front-end and a back-end pick different answers for where the body ends, an attacker can hide a second request inside the first. Reject these messages instead of guessing one length and forwarding it. HTTP/1.1 says chunked framing wins over Content-Length, and an intermediary has to remove the conflicting length before passing the message on.
A mismatch with Content-Length creates parser pressure immediately -
POST /a HTTP/1.1
Host: api.example.test
Content-Length: 5
helloGET /b HTTP/1.1The first five body bytes are hello. The byte right after them is G. On a reusable connection, the parser can read that G as the start of another request line, as long as the bytes that follow form a valid message. If the sender actually meant the whole visible tail as body data, then the length is simply wrong. The parser cannot see the sender's intent. It goes by the framing.
A too-large length stalls differently -
POST /a HTTP/1.1
Host: api.example.test
Content-Length: 10
helloFive body bytes have arrived. Five more are still owed. The receiver waits for the rest, for a timeout, or for the connection to close. Your handler can sit there doing nothing, because the parser is still holding out for the declared body to finish.
Chunked bodies can become incomplete too -
5\r\n
hello\r\nThe terminating zero chunk is still missing. The receiver has one complete chunk, but the message body is not complete. The parser waits for the next chunk-size line, a timeout, or connection close.
Connection-close framing handles responses whose length is given by the end of the connection. A server can send headers, body bytes, and then close the connection to mark the body end -
HTTP/1.1 200 OK
Content-Type: text/plain
Connection: close
helloHere, the response lacks Content-Length and chunked coding. The close marks the body end. The connection is consumed by this response. The transport ending is the delimiter, so the response sequence ends there.
Requests use a different default. A request carrying neither Content-Length nor transfer coding has zero body bytes. A server reads the next byte after the empty line as the next request start only when another request arrives on that connection. Close-delimited request bodies would break the request and response sequencing HTTP/1.1 relies on.
Body framing is also what marks a message complete. A valid Content-Length completes it after exactly that many bytes. Chunked completes it after the terminating zero chunk and the trailer section. A close-delimited response completes when the connection closes following a valid header section. A zero-body message completes at the empty line.
This logic sits under Node's HTTP behavior. req can be created after the start line and headers. The request body stream then produces bytes according to framing. message.complete later reflects whether the parser saw a complete HTTP message. Keep-alive reuse waits on full consumption or discard of the body because the parser must reach the next start line cleanly.
A handler that ignores the request body can still wreck connection reuse, because those unread body bytes stay on the connection ahead of the next request. The Node server lifecycle chapter handles the object-level details. The wire-level point is already in view, since a connection only advances once the current message has a known end.
Body limits land at the seam between protocol handling and application policy. A server can turn a request away because the declared length is too large, because the chunk stream runs past a configured cap, or because the body bytes arrive too slowly. Those checks live above the raw wire syntax, yet they fire while the parser is still framing the message. The route handler can end up on an early rejection path instead of receiving a full body.
For a streaming upload, body framing and backpressure work together. The HTTP parser identifies body bytes and feeds them into the request stream. The stream machinery from Chapter 3 decides how fast JavaScript consumes those bytes. The parser still owns message completion. Backpressure can slow delivery to your handler, but the HTTP body end still comes from framing.
Connection Semantics
HTTP/1.1 connection behavior sits above TCP connection state.
TCP can hold a socket open, and HTTP decides whether another message gets to use it. Those are two separate decisions. A TCP keep-alive probe is a transport thing. HTTP keep-alive is about message sequencing, framing, and connection metadata.
HTTP/1.1 defaults to carrying more than one request/response exchange on a connection when both sides keep it open and the current message is fully framed. The receiver has to know exactly where one message ends before it can parse the next start line.
Content-Length and chunked transfer coding support reuse. Connection-close framing can complete a response, but it consumes the connection after that response.
A reusable HTTP/1.1 connection follows this loop -
read request
send response
message complete
read next requestThe loop stops when either side sends Connection: close, when the body uses connection close as its ending, when a parser error hits, or when a local timeout tears the socket down. Timers, agents, and pools are Subchapter 5. The wire-level rule is already settled here, since a connection that still holds unread body bytes is still inside the current message.
The Connection header affects the current connection only. Resource state, cache policy, and retry policy live elsewhere. This field tells the immediate recipient which connection options apply to this hop. That narrow scope becomes important once intermediaries enter the picture, because a field meant for one hop can be wrong or dangerous on the next hop.
The Connection header carries control options for the current connection -
GET /report HTTP/1.1
Host: api.example.test
Connection: closeclose says the sender wants the connection closed after the current response. A server can also send it -
HTTP/1.1 200 OK
Content-Length: 5
Connection: close
helloHere, the body length is five bytes, and the connection will close after the response. The length still counts because it tells the client where the body ends before the close happens. That can affect whether the client reports a complete response or a truncated one.
If the server sends only three body bytes and closes, the client has an incomplete response -
HTTP/1.1 200 OK
Content-Length: 5
Connection: close
helThe close happened, but the declared body length was five. The body delivered three. A correct client reports incomplete transfer instead of treating the close as a successful body end.
A close-delimited response handles the same bytes differently -
HTTP/1.1 200 OK
Connection: close
helNow the close itself supplies the body end. The body is three bytes because this response chose connection-close framing. The TCP close signal is identical to the previous example, but the HTTP framing state behind it is different.
Close-delimited framing carries no length, so a connection reset mid-transfer is indistinguishable from a body that finished cleanly. The receiver takes the truncated data as the full response. Content-Length and chunked framing both catch a short read, while connection-close framing does not. Use explicit framing for any response where a half-delivered body would cause real damage.
The Connection field can also list other connection-specific fields. Those fields are meant for the immediate hop only. A forwarding component has to strip or replace them rather than pass them along untouched. Proxy behavior is Subchapter 7, though the reason starts right here, since some header fields describe only the current connection while others describe the end-to-end message.
Connection reuse depends on fully consuming each message. Take a server that receives this request -
POST /upload HTTP/1.1
Host: api.example.test
Content-Length: 1000000If the handler sends an early final response after inspecting only headers, the request body bytes may still be arriving. The server then has a protocol choice. It can continue reading and discard the remaining body so the connection reaches the next request start. Or it can close the connection. Leaving unread body bytes in front of the parser would corrupt the next request.
Sending a response does not end the message on the wire if you never read the body. On a keep-alive connection those undrained body bytes sit where the next start line should be, and the following request gets parsed as garbage. So after an early response, either drain the rest of the body or close the connection. Node will close the socket for you in many cases, but leaning on that quietly throws away the keep-alive reuse you wanted.
Clients face the same issue on the response side. A client that wants to reuse a connection has to consume or discard the whole response body according to framing. Stop reading halfway through a chunked response and the next bytes on the socket are still chunk data rather than a fresh status line. A pool can only hand that socket to another request after the parser reaches message completion.
HTTP/1.1 also associates responses with requests by order. A final response completes the oldest outstanding request on that connection, with any preceding informational responses attached to it. That keeps connection state small. It also means the parser has to get every body end right, because one bad body end shifts every response after it.
How a connection closes carries protocol meaning too. A clean close after a complete close-delimited response finishes that response cleanly. A close before a declared Content-Length is reached leaves the message incomplete. A reset during body transfer is a transport failure caught while the HTTP parser was waiting for more bytes. The exact Node error surface comes up later, and even now the parser state tells you which kind of failure you are looking at.
That shows up in logs. "Socket closed" is weak by itself. "Socket closed after complete chunked response" means the message completed and then the transport ended. "Socket closed with 30 bytes remaining from Content-Length" means incomplete HTTP. The same TCP event can happen at different HTTP states.
Agents and pools push this rule under load later on. The version you need for debugging is short. Connection reuse is a parser outcome, and an open socket only becomes reusable once the parser has reached the end of the current message.
Informational Responses
A 1xx response is an informational one, sent ahead of the final response for the same request. It reports protocol progress instead of the final result.
HTTP/1.1 100 Continue
HTTP/1.1 201 Created
Content-Length: 0The client receives two response messages for the one request. The first reports progress, the second is the real result, and that final status code is what completes the request.
The most common 1xx is 100 Continue. A client sends its request headers with Expect: 100-continue when it has a body to send and wants the server to decide early, from the request line and headers alone -
PUT /large.bin HTTP/1.1
Host: uploads.example.test
Content-Length: 104857600
Expect: 100-continueThe server can send 100 Continue, which tells the client to send the body. Or the server can send a final error response based on the method, target, and headers. Node exposes this path through checkContinue and client-side events later. At the wire level, the client can observe protocol messages before the final status code.
For a large request body, send the headers with Expect: 100-continue and hold the body back until the server answers 100 Continue. The server can then reject on the request line and headers alone, say for auth or an oversized upload, without ever receiving the bytes. If no interim response shows up within the client's continue timeout, the client sends the body anyway, so the server still has to handle a body that starts before it replied.
A full exchange runs in this order -
client sends request line and headers
server sends HTTP/1.1 100 Continue
client sends request body
server sends final responseThe request has one final response. The 100 Continue message is an interim response on the same connection, associated with that request by order.
Informational responses have zero body bytes. They end at the empty line after their header section. After that, the connection remains positioned for the next response message belonging to the same request.
Client code that assumes one status line per request can trip over this sequence. HTTP/1.1 allows one or more 1xx responses ahead of the final one. The final response is the one with a status code outside 1xx. Body framing belongs to that final response, apart from the special upgrade and tunnel cases covered later.
The parser has to surface these events while keeping their order. A 100 Continue is a zero-body response message with informational semantics, kept separate from the final response.
Other 1xx responses exist, including the ones that switch protocols. WebSocket and HTTP/2 upgrades are later chapters. The rule for this section holds either way, since a 1xx response can appear before the final one, and each 1xx message completes at its own empty line.
Reading Raw Messages Safely
A raw request is easier to reason about when you keep the end of the header section visible.
const raw = Buffer.from([
'GET /users?id=1 HTTP/1.1',
'Host: api.example.test',
'Accept: application/json',
'',
'',
].join('\r\n'));That buffer contains a request line, two header fields, the empty line, and zero body bytes. The final empty string creates the extra CRLF that ends the header section.
When something seems wrong, inspect the actual bytes -
console.log(raw.toString('latin1'));
console.log(raw.length);latin1 gives you one JavaScript character per byte, which is handy for this kind of inspection. Treat it as a display choice for debugging, not as a way to parse HTTP into Latin-1 strings.
Hex output exposes the CRLF delimiters directly -
for (const byte of raw) {
process.stdout.write(byte.toString(16).padStart(2, '0') + ' ');
}Look for 0d 0a at each line ending and 0d 0a 0d 0a at the end of the header section. If the capture display hides line endings, hex usually settles the question fast. You can see whether the request line ended, whether the header section ended, and where body bytes begin.
For a body-bearing request, use the same byte discipline -
const body = Buffer.from('{"ok":true}');
console.log(body.length);That length is the value a matching Content-Length field needs. When the source string contains escapes or non-ASCII code points, Buffer.byteLength() or an actual Buffer is a better source of truth than a visual character count.
For inspection, mark the parts before interpreting values -
request line GET /users?id=1 HTTP/1.1
header Host: api.example.test
header Accept: application/json
empty line end of headers
body zero bytesFramework routing and JSON parsing both sit further up the stack. The HTTP message here is already complete, because the request carries no body framing fields.
A raw response has the same outer form, with a different start line -
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 11
{"ok":true}Mark it the same way -
status line HTTP/1.1 200 OK
header Content-Type: application/json
header Content-Length: 11
empty line end of headers
body 11 bytesCount the body bytes rather than the displayed field lines. The Content-Length value starts counting after the empty line. Header bytes stay outside it.
Chunked inspection needs one extra pass -
HTTP/1.1 200 OK
Transfer-Encoding: chunked
6
hello!
0The header section ends at the first empty line. The body framing then switches to chunk parsing. 6 says six body bytes follow. hello! supplies those six bytes. 0 ends the chunked body. The final empty line ends the trailer section, which is empty here.
When the framing disagrees with the visible text, go with the framing rules over the display. A body can hold \r\n\r\n as plain data when Content-Length counts those bytes into the body. A chunk can hold bytes that read like header fields. A field value can advertise JSON while the body is still arriving. Structure comes from HTTP syntax and framing first, and the application parsers run on top of that.
This body contains a line that reads like a header -
POST /note HTTP/1.1
Host: api.example.test
Content-Length: 13
X-Test: helloX-Test: hello is body data here. The header section ended at the empty line. The colon has application meaning only if the body parser gives it meaning.
Body bytes can also resemble a whole new HTTP message -
POST /note HTTP/1.1
Host: api.example.test
Content-Length: 14
HTTP/1.1 500!!The body starts with characters that resemble a status line. The current parser state says fixed-length request body, so those bytes stay inside the body until 14 bytes have been consumed. After that point, the parser returns to start-line mode.
A raw log that starts partway into a body can read as protocol syntax even while the connection is still deep inside application content.
Watch for this when you copy small fragments from packet captures or debug logs. A fragment beginning with GET or HTTP/1.1 proves very little by itself. The previous parser state decides whether those bytes are protocol control bytes or body bytes. Find the start line, end of headers, and selected framing rule before assigning meaning to a fragment.
A compact debug workflow works well -
1. Find the first CRLF - the start line ends there.
2. Find CRLFCRLF - the header section ends there.
3. Parse framing fields and status/method rules.
4. Count body bytes or chunks.
5. Only then parse body content.That workflow is for reading captures and tests. Production HTTP parsing belongs to Node's parser, because valid HTTP includes edge cases around whitespace, repeated fields, oversized fields, invalid bytes, partial reads, and connection state.
The real skill is knowing which layer a failure belongs to. A malformed request line fails before routing. A missing Host field fails before any resource handling. A bad Content-Length fails while the body is being framed. A JSON parse error only happens once HTTP has already delivered the body bytes. And a connection that closes before the declared body length is reached is an incomplete HTTP message, however meaningful the partial body looked.
This small placement table helps during incident notes -
bad start line parser rejects request syntax
bad header field parser rejects header section
bad framing parser lacks complete body
bad JSON body parser rejects content
handler error application code rejects requestThose failures happen at different layers. They produce different logs, different status codes, and different retry behavior later.
From here the rest of the chapter takes over. Subchapter 2 turns these messages into http.Server, IncomingMessage, and ServerResponse, and Subchapter 3 opens up the parser path. The bytes on the wire do not change. What changes is that Node starts giving the pieces names you can use from JavaScript.