Lesson 2.1
Why not toString().split()
What a request looks like on the wire, why the obvious toString().split() parse is both wasteful and unsafe, and the state machine that replaces it - proven by measuring the garbage which split leaves behind.
- Read a raw HTTP request off the wire and name the request line, field lines and header block terminator.
- State the two reasons why toString().split() is the wrong tool - intermediate garbage and no way to resume.
- Measure the allocation and minor-GC cost of split versus a byte scan on identical input.
Last chapter you answered every connection with the same thirteen bytes and never even looked at the request. This chapter reads it. Converting the complete buffer to a string and splitting it creates intermediate strings and arrays. Also, it needs the complete request before parsing can even start. In this lesson you'll measure those costs yourself.
The request, byte by byte
Here is what actually lands on the socket when a client makes a request. These are the exact octets, with the invisible line endings drawn in.
Figure 2.1 - A request line and field lines are followed by one empty line that terminates the header block.
Last chapter you wrote a response by hand; now you'll read a request, and the vocabulary is the same - RFC 9112's, the terms you froze in Chapter 1. Line one is the request line. Each name: value line is a field line (what most people loosely call a header). The bare CRLF on a line by itself, \r\n\r\n on the wire, is the header block terminator i.e the one signal that the head has ended. Everything up to that terminator is what you'll parse in this chapter. A body may come after it, but reading the body by content-length is next chapter's job. Today you stop at the blank line.
The grammar of the request line is rigid. RFC 9112 spells it out like this.
request-line = method SP request-target SP HTTP-version CRLFSP is one space, the byte 0x20. Three tokens, two spaces between them, and a CRLF at the end. The method is a case-sensitive token (GET, POST, ...). The request-target, for our purposes, is an origin-form, means an absolute path, optionally followed by ? and a query string. The HTTP-version is literally HTTP/1.1 or HTTP/1.0. Nothing else is legal in that slot.
Parsing after converting the whole buffer
Here is the parser that comes to everyone's mind first.
const text = buffer.toString(); // the WHOLE request becomes a string
const lines = text.split("\r\n"); // an array of line strings
const [method, target, version] = lines[0].split(" "); // more strings, another arrayJust three lines, but they have two separate problems.
The first problem is garbage. toString() copies every byte of the request into a fresh V8 string. split("\r\n") allocates an array plus a new string for every line. lines[0].split(" ") allocates yet another array and three more strings. For a request where your router only looks at the method and path, you have built a whole-request string, an array of line-strings, and a sub-array of three - and all of it becomes garbage the moment you read the two tokens you wanted. You'll measure how much this costs at the end of the lesson.
The second problem is that split cannot resume, and HTTP arrives in fragments. String.prototype.split works on one complete string. TCP is a byte stream, so a 'data' event may contain half a request, or one and a half requests. A parser built on split cannot note down that it stopped inside the accept field value and continue from that index after more bytes arrive. It has to wait for a complete string before doing any parsing at all.
The state machine, drawn once
A protocol parser is a finite state machine i.e a small set of named states, plus a rule for which state you move to when you read the next byte. A state machine can stop in the middle of a token, remember where it was, and pick up again when more bytes come. split has nothing like that.
The series freezes all eight state names in this chapter itself, so this diagram stays valid when Chapter 10 adds chunked bodies.
Figure 2.2 - Header completion either finishes the message or transfers control to fixed-length or chunked body states.
The eight states, with their frozen integer values.
REQUEST_LINE = 0 scanning "METHOD SP TARGET SP VERSION CRLF"
HEADERS = 1 field lines until the header block terminator
READ_BODY = 2 fixed-length body by content-length (next chapter)
CHUNK_SIZE = 3 hex size line of a chunk (Chapter 10)
CHUNK_DATA = 4 payload bytes of a chunk (Chapter 10)
CHUNK_CRLF = 5 the CRLF after a chunk payload (Chapter 10)
TRAILERS = 6 optional trailer section (Chapter 10)
DONE = 7 message complete; parser self-resetsThis chapter uses REQUEST_LINE, HEADERS, and DONE. You still declare all eight states. The parser is an explicit byte-level state machine with one named state active at a time. A regular expression, regex in short, is a text-matching pattern. This parser doesn't use one anywhere.
One term you should freeze before writing any code. When the parser cannot finish because the bytes it needs haven't arrived yet, it returns null. The prose name for that condition, written in capitals, is WANT_MORE. There is no exported WANT_MORE constant anywhere in the series - returning null is want-more. This chapter barely uses it (you assume one data event carries one whole request), but the parser already speaks it, and the next chapter is fully built around it.
What the scan buys, and what it costs
The buffer that read(2) handed you already contains every byte of the request, sitting right there in memory. The method GET is three bytes at offset 0. The path is some bytes a little later. You don't need to copy those bytes to know where they are - you just need two numbers, a start and an end. That is the whole idea of zero-copy parsing. The method, the path, each header name and value are windows onto the arrival buffer, described by offsets, and a JavaScript string comes into existence only when someone actually asks for one.
Buffer.prototype.subarray(start, end) returns a new Buffer view that shares the original memory. A view has its own offset and length but it doesn't copy the selected bytes. Turning that range into a string needs more work though. buf.toString("latin1", start, end) allocates a string on the heap and copies the bytes into it. The heap is the memory area where JavaScript objects and strings live. The garbage collector later reclaims objects which the program can no longer reach. To materialize a token means to create its string from the buffer range.
V8 calls a minor garbage-collection pass a Scavenge. It examines the young generation, where newly allocated objects normally begin their life. Short-lived strings increase the number of these passes. Scan bytes to find token limits. Materialize a string only for a token you keep.
The chapter title describes the final parser. This chapter still builds one string per stored header name and value. But it avoids the intermediate whole-request string, the line array, and the per-line arrays. A later chapter changes header storage to offsets without changing the public accessors.
The measurement terms
A microbenchmark times one small operation many times over. It can report misleading results if the two functions perform different work, or if the program never uses their results.
V8 first runs a function normally before deciding whether to compile optimized machine code for it. Calling the function before timing is called warmup. The JIT compiler creates optimized code for frequently executed functions while the program runs. Dead-code elimination removes work whose result can never affect the program. Adding every result to sink and printing sink prevents that removal.
performance.now() returns a timestamp in milliseconds with fractions. Throughput is the number of completed parses per second, printed here as req/s. process.argv is the array of command-line arguments given to the Node process. The exercise reads index 2 because indexes 0 and 1 contain the Node executable and the script path.
[!INFO] A hexadecimal number uses base 16. The digits after 9 are
atof. JavaScript's0xprefix marks hexadecimal, so0x20and decimal32name the same space byte.
Prove the thesis with an actual number. Build a throwaway measurement, scratch/alloc.mjs, that parses one fixed request in two different ways and lets you compare their throughput and garbage. (scratch/ is scratch space, not framework code - nothing from here ships.)
Read only the method and path, because that is what a router actually needs. This is also the realistic case, where the scan gets to skip work which split cannot skip.
Your scratch/alloc.mjs must do the following.
- Define one fixed request
Bufferwith a handful of headers (seven is realistic), built once from alatin1string. - Provide
naive(buf)- thetoString().split()version. Turn the whole buffer into a string, split on"\r\n", split line zero on" ", and find the path by cutting the target at its first?withindexOf. Return some number derived from the result (for examplemethod.length + path.length) so the optimizer cannot delete the work. - Provide
scan(buf)- a byte scan. Walk a cursor to the first space (SP = 0x20) for the method, step over it, walk to the next space for the target, then scan the target's bytes for the first?(QMARK = 0x3f) to find where the path ends. Materialize the method and path withbuf.toString("latin1", start, end). Return the same number asnaiveon the same input. - Select the function from
process.argv[2](naiveorscan). - Warm up. Call the chosen function tens of thousands of times before timing, so that V8 has compiled the loop. Accumulate every return value into a
sinkvariable and print it at the end. - Time
Niterations (five million is a goodN) withperformance.now(), and print the mode, the milliseconds, the requests per second, and thesink.
Both modes must compute the identical result on the identical input. Matching sink values are your proof that you measured the same work in two ways.
Reveal these one at a time, and only when you are genuinely stuck. Each one tells you a little more than the last - none of them is the answer.
Run each mode under --trace-gc, which prints one line per garbage collection, and count the Scavenge lines - that is your minor-GC pressure.
node --trace-gc scratch/alloc.mjs naive > /tmp/naive-gc.txt 2>&1
grep -c "Scavenge" /tmp/naive-gc.txt
grep "req/s" /tmp/naive-gc.txt
node --trace-gc scratch/alloc.mjs scan > /tmp/scan-gc.txt 2>&1
grep -c "Scavenge" /tmp/scan-gc.txt
grep "req/s" /tmp/scan-gc.txtOn a warm machine the scan should report more parses per second and fewer Scavenge lines. One measured run looked like this.
# naive: ~3300 Scavenge lines, ~2,600,000 req/s
# scan : ~230 Scavenge lines, ~8,400,000 req/sThat's roughly three times the throughput and around fourteen times fewer scavenges, on identical input, computing the identical result (the matching sink values prove it). The exact numbers depend on your hardware, the ratio is the point. The split version cannot help it - to hand you lines[0].split(" ")[0] it first built the whole-request string, an array of line-strings, and a sub-array of three, all garbage, even though you only wanted two byte-ranges. The scan touched each byte once and materialized exactly the two strings you asked for.
One honesty check, so that you carry the right lesson away. If you extend both modes to materialize every header into a map, the gap collapses - V8's split and toLowerCase are heavily optimized C++, and a tight warmed loop can put the scan at parity or even slightly behind on that workload. The durable win of the scan is somewhere else. It creates no intermediate garbage, and it lets you defer materialization for as long as you want. split forces both on you, every single time.
- Both modes report absurdly high, identical throughput. Means you are not observing the result, so V8 deleted the loop as dead code. Accumulate every return into
sinkand print it. - Numbers swing wildly between runs. You skipped the warmup and timed the interpreter before the JIT compiled the loop. Run the function tens of thousands of times before starting the clock.
- The
sinkvalues differ betweennaiveandscan. Means the two functions are not computing the same thing, so the whole comparison is meaningless. Make both return the identical quantity on the identical input. - You conclude the scan is faster for every workload. It isn't. Materializing every field can put the byte scan near or even behind V8's optimized string operations. The scan still avoids intermediate values and permits deferred conversion.
You measured the intermediate allocations from toString().split(), and the byte scan that avoids them. The parser's eight named states include WANT_MORE, represented by a null return when more bytes are needed. The next lesson adds the error type and the early response used for malformed input.