Lesson 05 / 19
Hydration and Streaming
Server-produced markup gaining interactivity on the client; hydration's cost, its partial and lazy forms, moving the first byte earlier with chunked delivery, and the balance the two techniques strike together.
Contents
The Server-Side Rendering lesson left a gap open: the moment the body reaches the screen and the moment the button responds are not the same moment. The markup was produced on the server; event listeners are attached only after the script runs. This lesson names that interval and narrows it from two directions.
The process that closes the gap is called hydration in this course: taking the static markup that arrives from the server and matching it with code running on the client to make it interactive. The term is used consistently throughout the course. The second technique is sending the response piece by piece rather than as a single chunk; this is called streaming.
What Hydration Does
In the document that arrives from the server, the tree is already built. The client code’s job is not to rebuild the tree but to match its own component tree against the tree that arrived: finding which node corresponds to which component, attaching event listeners, and setting up state objects.
This matching has three costs. The components’ code must be downloaded. The downloaded code must be parsed and executed. A match must be made for every node in the tree, and listeners must be attached. The third item grows linearly with the node count as the page grows.
Matching also has a correctness condition: the tree the client produces must be identical to the server’s. If a value was written on the server based on the clock at that moment, and the client sees a different clock, the two trees diverge. This divergence does not stay silent; the part that fails to match is either produced from scratch or left as an inconsistent interface. This is why markup meant to be hydrated must be deterministic: the same input must produce the same output on both sides.
Sending the Response Piece by Piece
The document has to arrive before hydration can begin. If the document is produced as a single chunk, the slowest data source holds up the entire page. Sending it in pieces breaks that link: the server sends each section the moment it is ready.
// stream-server.mjs — serves the same page in two delivery forms import { createServer } from "node:http"; const wait = (ms) => new Promise((c) => setTimeout(c, ms)); // The page's three sections; each comes from a separate data source and takes 200 ms. const SECTIONS = [ ["heading", '<h1>North Slope Measurement Station</h1>'], ["measurements", '<ul><li>06:00 -4.2 C</li><li>07:00 -3.8 C</li><li>08:00 -2.1 C</li></ul>'], ["comment", '<p>19 measurements below freezing in the last 24 hours.</p>'], ]; const read = async ([, body]) => { await wait(200); return body; }; const START = `<!doctype html> <html lang="en"><head><meta charset="utf-8"><title>North Slope</title></head><body>`; const END = `</body></html>\n`; createServer(async (request, response) => { response.sendDate = false; if (request.url === "/single-chunk") { const chunks = []; for (const section of SECTIONS) chunks.push(await read(section)); const body = START + chunks.join("") + END; response.setHeader("Content-Type", "text/html; charset=utf-8"); response.setHeader("Content-Length", Buffer.byteLength(body)); response.writeHead(200).end(body); } else if (request.url === "/streamed") { response.setHeader("Content-Type", "text/html; charset=utf-8"); response.writeHead(200); // Content-Length is not written: chunked delivery begins response.write(START); // the shell heads out without waiting for (const section of SECTIONS) response.write(await read(section)); response.end(END); } else { response.writeHead(404).end(); } }).listen(8175, "127.0.0.1", () => console.log("listening: 127.0.0.1:8175"));
Seeing when the chunks arrive requires a reader; curl reports only the first and last
moments.
// reader.mjs — writes down the moments a response's chunks arrive // Usage: node reader.mjs /streamed import { get } from "node:http"; const path = process.argv[2] ?? "/streamed"; const start = performance.now(); const elapsed = () => (performance.now() - start).toFixed(0).padStart(4); get({ host: "127.0.0.1", port: 8175, path }, (response) => { console.log(`${elapsed()} ms response header Transfer-Encoding: ` + `${response.headers["transfer-encoding"] ?? "(none)"} ` + `Content-Length: ${response.headers["content-length"] ?? "(none)"}`); response.on("data", (chunk) => { const preview = chunk.toString().replace(/\n/g, " ").slice(0, 46); console.log(`${elapsed()} ms chunk ${String(chunk.length).padStart(3)} B ${preview}`); }); response.on("end", () => console.log(`${elapsed()} ms response finished`)); });
The script below runs in the same directory as these two files. Port 8175 is chosen arbitrarily and must be free; if it is in use, change it in all three files.
#!/usr/bin/env bash # Starts stream-server.mjs, compares the two delivery forms, then stops it. node stream-server.mjs > /dev/null & server=$! sleep 1 echo "--- time to first byte (curl) ---" for path in /single-chunk /streamed; do printf ' %-14s ' "$path" curl -sS -o /dev/null -w 'first byte %{time_starttransfer} s total %{time_total} s\n' \ "http://127.0.0.1:8175$path" done echo "--- /single-chunk: arrival of the chunks ---" node reader.mjs /single-chunk echo "--- /streamed: arrival of the chunks ---" node reader.mjs /streamed kill "$server"
--- time to first byte (curl) --- /single-chunk first byte 0.611729 s total 0.611903 s /streamed first byte 0.001132 s total 0.606853 s --- /single-chunk: arrival of the chunks --- 611 ms response header Transfer-Encoding: (none) Content-Length: 285 613 ms chunk 285 B <!doctype html> <html lang="en"><head><meta ch 614 ms response finished --- /streamed: arrival of the chunks --- 3 ms response header Transfer-Encoding: chunked Content-Length: (none) 3 ms chunk 99 B <!doctype html> <html lang="en"><head><meta ch 206 ms chunk 40 B <h1>North Slope Measurement Station</h1> 408 ms chunk 72 B <ul><li>06:00 -4.2 C</li><li>07:00 -3.8 C</li> 610 ms chunk 59 B <p>19 measurements below freezing in the last 610 ms chunk 15 B </body></html> 612 ms response finished
Duration fields depend on the machine; they come out different on every run. The interpretation rests on two ratios.
Time to first byte shrank by roughly five hundred times. In the single-chunk response, the first byte waits on all three sections; in the streamed response, the shell heads out without asking any data source first. Total time did not change: both responses finish in about 610 milliseconds. Streaming does not reduce the work; it starts sending the work’s result without waiting for it.
The difference in the headers is this behavior’s counterpart at the transport layer.
Because the body’s length is unknown when delivery begins, Content-Length cannot be
written; transport is chunked with Transfer-Encoding: chunked, and each chunk reports its
own length.
The gain finds its counterpart on screen too, because the document parser processes incoming bytes without waiting for the rest: once the shell arrives, style files can be requested; once the heading arrives, it can be painted. Content appears in sequence.
The Constraints Streaming Brings
The response’s header goes out with the first chunk and cannot be changed afterward. This has three consequences.
The status code cannot be taken back. If the third section’s data cannot be retrieved, 500 cannot be returned; the header has already gone out as 200. The error has to be told as a section inside the body. This is why, in a streamed response, every section defines its own error markup.
Redirection cannot be done. Decisions that could cancel the entire response, such as authentication, must be made before the first byte is sent.
Order becomes a design decision. Sections go out in the order they are written in the document. If the slowest section is placed first, streaming’s gain disappears. This is why sections are ordered not by their speed but by what the user needs to see first, and a loading indicator is written in place of slow sections until they arrive.
Splitting Up Hydration
Streaming moves the document earlier; hydration’s cost stays where it was. The way to reduce it is to not hydrate the entire page.
Partial hydration hydrates only the interactive components. The measurement table is a block of text; it listens to no event and holds no state. Hydrating a section like this is wasted work.
Lazy hydration means not hydrating interactive components immediately either. A chart that sits outside the first screen can be hydrated when it enters the viewport, or on first interaction. The intersection observer, introduced in the Observers lesson, is the natural tool for this trigger.
// hydration.mjs — first-load cost of the same page under three hydration modes // Inputs are assumptions; the output is these assumptions' arithmetic, not a measurement. const CORE_KB = 25; // shared runtime shipped in every mode const PARSING_MS_KB = 0.35; const NODE_MS = 0.02; // cost of wiring one node up for interaction // The station page's component breakdown. const COMPONENTS = [ { name: "page shell", nodes: 120, interactive: false, kb: 4, firstScreen: true }, { name: "navigation menu", nodes: 40, interactive: true, kb: 6, firstScreen: true }, { name: "measurement table", nodes: 520, interactive: false, kb: 9, firstScreen: true }, { name: "filter bar", nodes: 30, interactive: true, kb: 14, firstScreen: true }, { name: "chart", nodes: 260, interactive: true, kb: 48, firstScreen: false }, { name: "comment box", nodes: 45, interactive: true, kb: 12, firstScreen: false }, { name: "footnotes", nodes: 90, interactive: false, kb: 3, firstScreen: true }, ]; const sum = (list, field) => list.reduce((t, b) => t + b[field], 0); const TOTAL_KB = sum(COMPONENTS, "kb"); const INTERACTIVE = COMPONENTS.filter((b) => b.interactive); // Three modes: what gets hydrated on first load, and whose code is needed at all, differ. const MODES = [ ["full hydration", () => true, COMPONENTS], ["partial hydration", (b) => b.interactive, INTERACTIVE], ["lazy hydration", (b) => b.interactive && b.firstScreen, INTERACTIVE], ]; console.log("mode".padEnd(24) + "first load KB".padStart(15) + "nodes".padStart(8) + "first load ms".padStart(15) + "later KB".padStart(11) + "never sent".padStart(13)); for (const [name, selector, needed] of MODES) { const hydrated = COMPONENTS.filter(selector); const firstLoadKb = CORE_KB + sum(hydrated, "kb"); const nodes = sum(hydrated, "nodes"); const duration = firstLoadKb * PARSING_MS_KB + nodes * NODE_MS; const later = sum(needed, "kb") - sum(hydrated, "kb"); const never = TOTAL_KB - sum(needed, "kb"); console.log(name.padEnd(24) + firstLoadKb.toFixed(0).padStart(15) + String(nodes).padStart(8) + duration.toFixed(1).padStart(15) + later.toFixed(0).padStart(11) + never.toFixed(0).padStart(13)); } const totalNodes = sum(COMPONENTS, "nodes"); const interactiveNodes = sum(INTERACTIVE, "nodes"); console.log(`\ntotal nodes ${totalNodes}, interactive nodes ${interactiveNodes} ` + `(${((interactiveNodes / totalNodes) * 100).toFixed(0)}%)`);
mode first load KB nodes first load ms later KB never sent full hydration 121 1105 64.4 0 0 partial hydration 105 375 44.3 0 16 lazy hydration 45 70 17.1 60 16 total nodes 1105, interactive nodes 375 (34%)
The last line is the basis for the decision: only 34 percent of the page’s nodes are interactive. Full hydration also performs matching for the remaining 66 percent and ships those components’ code.
In the partial mode, first-load time drops from 64.4 milliseconds to 44.3 milliseconds; the gain comes from the nodes. In the lazy mode, the time falls to 17.1 milliseconds, because the first load has only 70 nodes and 45 kilobytes; 60 kilobytes are left for later. The last column shows that the static components’ 16 kilobytes of code are never sent at all: this is not a deferred cost but one that disappears entirely.
The lazy mode’s cost is paid on the first touch of the deferred component. When the user clicks the chart, 48 kilobytes of code are requested, and the interaction is delayed by that much. This is why deferral is applied to components outside the first screen, or ones unlikely to be used — not to where the first interaction is expected.
What the Two Together Produce
Streaming and partial hydration touch two different line items and do not substitute for each other. Streaming moves the document’s first byte earlier: the user sees content sooner. Partial hydration moves the moment of time to interactive earlier: the user can click sooner.
When the two are used together, every section of the page matures at its own pace. The shell arrives immediately, sections appear in sequence, and interactive sections come alive as their code arrives. This arrangement’s natural measure is not a single number; it is a per-section pair of “appeared” and “hydrated” moments.
The cost paid is complexity. Section boundaries, section order, each section’s loading and error state, hydration triggers — all of it has to be defined explicitly. If a page has three sections and two deferred components, the number of states to test is many times more than for a single-chunk response.
Summary
- Hydration is matching the static markup that arrives from the server against the client’s component tree to make it interactive; it has three costs: downloading the code, parsing it, and matching per node.
- Matching’s condition is determinism: if the tree the server and the client produce diverges, the affected section is either produced from scratch or stays inconsistent.
- Streaming moves the first byte earlier but does not change the total time; because the
body’s length is unknown, chunked transfer is used instead of
Content-Length. - Streaming’s cost is that the header cannot be taken back: status code and redirection decisions must be made before the first byte is sent, and errors must be told inside the body.
- Partial hydration hydrates only interactive components, lazy hydration hydrates only those on the first screen; on the measured page, first-load time drops from 64.4 milliseconds to 44.3 and 17.1 milliseconds respectively.
Next Step
These two techniques leave the distance between the server and the user as it was. The first lesson’s chain calculation assumed a 60-millisecond network round trip, and this line item did not shrink with a code change: the signal has to cross the physical distance in between. If the server sits in a single place, a distant user pays this cost on every round trip. The next lesson takes up bringing rendering geographically closer to the user: converting distance into latency, what a layer running at the edge can and cannot do, and the division of labor with the origin server.
To keep your progress and take notes, Log in
My notes
Log in to take notes.