---
title: 'Hydration and Streaming'
source: 'https://academia.sh/en/courses/rendering-strategies/hydration-and-streaming'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:07+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

```bash
#!/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.

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