ParsingGENERALNETWORK-SPECIFICFRAMEWORK-SPECIFIC

Streaming HTML

The parser starts on the first chunk and never waits for the last one — which makes time to first byte, flush behaviour and document order performance decisions rather than server details.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

If the browser parses HTML as it arrives, what does that change about how I should generate and send it?

The user intent

A person wants to start reading, or start clicking, as soon as anything useful exists. They do not care that the recommendations panel is still being computed.

The obvious build

The server renders the page, then sends it, then the browser parses it. Making the page fast means making the server fast and the HTML small.

Why it breaks

The browser does not wait. It tokenizes, builds tree and paints from partial input, so a document whose first bytes contain the header and the article can show both while the footer is still being generated.

How it breaks in a real browser
  • The browser does not wait. It tokenizes, builds tree and paints from partial input, so a document whose first bytes contain the header and the article can show both while the footer is still being generated.
  • "Render then send" throws that away by construction. If the server buffers the whole response, the browser is idle for the entire generation time and then does all its work at once (Server-Side Rendering).
  • Anything in the pipeline can reintroduce the buffer: a template engine that returns a string, a compression layer with a large flush window, a proxy that wants to compute Content-Length, a CDN doing HTML rewriting, a framework that awaits every data dependency before rendering the root.
  • Document order becomes a performance decision. A render-blocking stylesheet referenced at the end of a long document is discovered late no matter how the server behaves (Render-Blocking Resources).
  • Incremental rendering is incremental *re*-rendering: each flushed chunk appends nodes that must be styled and laid out again, so a document that streams badly can shift content under someone who is already reading it (Visual Stability).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The response body is delivered as chunks — Transfer-Encoding: chunked on HTTP/1.1, DATA frames on HTTP/2 and HTTP/3. The browser hands each chunk to the parser as it arrives (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).
  • The tokenizer is resumable. It can stop mid-token at a chunk boundary and continue when more characters arrive, which is why a tag split across two TCP segments is a non-event.
  • The parser yields to the event loop periodically rather than running to completion, so a long document does not freeze the tab and the browser can paint what it has (The Event Loop, Precisely).
  • Painting is opportunistic. The browser will render a partial document once it has a usable CSSOM; there is no "document complete" gate before first paint (The Critical Rendering Path).
  • DOMContentLoaded fires when parsing finishes and deferred scripts have run. load waits for subresources. Neither is required for content to be visible (`defer`, `async` and `type="module"`).
  • Compression streams too. Gzip and Brotli are block-based and do not require the whole body; what breaks streaming is a server or proxy choosing to buffer, not the codec.
  • Out-of-order streaming — the technique behind streaming SSR — sends placeholders first, then late content in <template> elements plus a tiny inline script that relocates them. The parser is still strictly in order; the *content* arrives out of order (Streaming Server Rendering).

What this makes the browser do

And which of it is avoidable.

  • Re-entering the tokenizer and tree builder once per chunk, with the interleaving determined by network arrival rather than by anything in the document.
  • Style resolution and layout for the appended subtree on each rendering opportunity. Streaming trades one large layout for several smaller ones (The Rendering Opportunity).
  • Discovering subresources as they appear in the byte stream, plus speculative discovery ahead of the insertion point (The Preload Scanner).
  • Holding an incomplete document, which means holding the parser state, the stack of open elements and any partially-tokenized input across network waits.
  • Avoidable: the repeated layout, partly. Reserving space for late content with explicit dimensions or content-visibility limits how much is relaid out per chunk (content-visibility).

The browser starts before the server finishes

The two timelines below carry the whole lesson. Total bytes are identical, server work is identical, and the network is identical. The only difference is whether the server sent the head and shell before its slow data fetch resolved.

Notice what the streamed version gets for free: connection setup for the stylesheet origin, CSS download, CSS parsing and a first paint all happen inside a window the buffered version spends completely idle. Notice also what it does not get — the slow data does not arrive any sooner. Streaming does not accelerate the server; it removes the browser's idleness.

Buffered response versus streamed response, same workrelative units — a schematic shape, not a measurement
BUFFERED — server renders whole page
BUFFERED — HTML arrives
BUFFERED — parse
BUFFERED — CSS fetch (discovered now)
BUFFERED — first paint
STREAMED — head flushed immediately
STREAMED — CSS fetch (discovered at unit 1)
STREAMED — shell + header HTML
STREAMED — parse shell
STREAMED — server still fetching slow data
STREAMED — first paint (shell + skeleton)
STREAMED — slow region streams in
STREAMED — parse + paint late region
  • BUFFERED — server renders whole pageBrowser has the connection open and nothing to do. Every byte is withheld until the last one exists.
  • BUFFERED — CSS fetch (discovered now)Discovery could not happen earlier because no bytes had arrived.
  • STREAMED — head flushed immediatelyKnowable before any data fetch: title, stylesheet links, preloads.
  • STREAMED — CSS fetch (discovered at unit 1)The same download, started eleven units earlier. This is the entire win.
  • STREAMED — server still fetching slow dataUnchanged. The server is exactly as slow as before.
  • STREAMED — first paint (shell + skeleton)Content is on screen while the server is still working.
  • STREAMED — parse + paint late regionInto reserved space, so nothing already on screen moves.

Last byte lands at the same moment in both. First paint does not, and first paint is what the person waiting is looking at.

Everything between your renderer and the browser can buffer

Streaming is not a feature you enable in one place. It is a property of an entire chain, and any single hop that decides to hold the body until it is complete removes it for everyone downstream — silently, with a 200 and a correct-looking page.

The reliable test is not a config flag. Watch the bytes arrive: curl -N shows them landing over time, and the Network panel splits waiting from downloading. A response carrying Content-Length was buffered by definition, because computing it requires the whole body.

Where the stream gets buffered
TriggerSymptomCauseResponse
Renderer returns a string rather than a streamLong TTFB, short download, one big parse blockThe API shape requires the whole document before returningUse the stream-returning renderer. This is an API choice, not a configuration one (Streaming Server Rendering).
Reverse proxy or WAF inspects the bodyStreams locally, buffered in productionInspection requires the complete responseConfigure proxy buffering off for HTML routes, or move inspection to a mode that streams.
Content-Length present on an HTML responseNo incremental arrival at allComputing the length requires the whole bodyTreat the header as a diagnostic: its presence means something buffered upstream.
CDN performing HTML rewriting or edge injectionOrigin streams; the browser does not receive a streamSome transforms require full-document contextCheck whether the transform has a streaming mode; move injection into the origin if not (CDN Delivery).
Framework awaits all data before rendering the rootServer work and browser idleness overlap perfectlyNo boundary exists at which partial output could be emittedIntroduce suspense-style boundaries so the shell can be emitted while slow regions resolve.
Service worker constructs the response in memoryStreaming disappears only for returning visitorsrespondWith on a fully-read body buffersPass the stream through instead of reading it, and cache with streaming-aware code (Intercepting Fetch).
The two response shapes
1HTTP/1.1 200 OK
2Content-Type: text/html; charset=utf-8
3Content-Length: 48213
4
5... entire document, produced before the first byte was sent ...
6
7
8--- versus ---
9
10HTTP/1.1 200 OK
11Content-Type: text/html; charset=utf-8
12Transfer-Encoding: chunked
13
141f4
15<!doctype html><html lang="en"><head>
16<meta charset="utf-8">
17<link rel="stylesheet" href="/app.css">
18</head><body><header>...</header><main>
190
20... later chunks as the server produces them ...

On HTTP/2 and HTTP/3 there is no Transfer-Encoding header — the body is framed at the protocol level — but the distinction is the same one: is Content-Length present, and did the first byte arrive before the last one existed?

Progressive rendering is repeated rendering — and repeated announcing

Every flush that appends nodes gives the browser a rendering opportunity, and each opportunity costs style resolution and layout for whatever the new nodes affect. Most of the time that is cheap and local. It stops being cheap when the appended content participates in a layout that spans the whole document — a table whose column widths depend on all its rows, or a flex container that redistributes space as children arrive.

The same incrementality has an accessibility dimension that is easy to miss. Content that appears after a screen reader user has started reading is a change to a document they are already navigating. The default should be that it appears quietly, in reserved space, without touching focus.

What each streamed chunk costs the browser
ChangestylelayoutpaintcompositeWhy
Append a paragraph inside a normal block flowyesyesyesyesStyle is resolved for the new nodes; layout runs for the containing block and anything after it in flow. Local and cheap.
Append a row to a table with auto layoutyesyesyesyesColumn widths depend on all cells, so a new row can relayout every row already on screen. table-layout: fixed makes this local instead (Layout Thrashing).
Append content into a reserved-size skeletonyesmaybeyesyesIf the reserved box already has the final dimensions, layout is confined inside it and nothing after it moves — this is the whole point of reserving space.
Append an image with width and height attributesyesnomaybeyesThe box is sized from the aspect ratio before any bytes arrive, so decode and paint happen later without a second layout (Responsive Images).
Append an image without dimensionsyesyesyesyesZero-height until decoded, then it pushes everything below it down. The classic source of visual instability (Visual Stability).
Append a stylesheet link mid-documentyesmaybeyesmaybeRendering is blocked until it parses, then every element it matches is restyled — potentially the entire document already on screen (Render-Blocking Resources).
Append inside a subtree with `content-visibility: auto`maybemaybenonoOffscreen subtrees can skip layout and paint until they approach the viewport, which bounds the per-chunk cost of a long document (content-visibility).

caveat Every maybe here depends on what else is on the page: containment, the size of the affected containing block, and whether the appended subtree participates in a layout that spans other content. Measure the specific document rather than trusting the row (Measure Before Optimising).

accessibility specProgressively streamed page regionStreaming a document, accessibly

semantics Real landmarks in the first flush — <header>, <nav>, <main>, <footer> — with streamed regions as <section> elements carrying an accessible name. A skeleton is decorative and should not be exposed as content.

TabMoves through controls in DOM order. Because content is appended in document order, tab order stays stable as the page fills in.
Shift + TabMoves backwards. It must not land on a skeleton placeholder, which is why placeholders should not be focusable.
Screen-reader browse keys (H, landmark navigation)Navigates the structure that has arrived so far — which is why headings and landmarks belong in the first chunk.
Focus
  • Never move focus when a streamed chunk arrives. The user did not request it, and it destroys their reading position.
  • Placeholders and skeletons must not be focusable: no tabindex, no interactive elements inside them.
  • If a streamed region replaces a placeholder that currently holds focus — rare, but it happens with interactive skeletons — move focus to the equivalent element in the replacement rather than letting it fall to <body>.
Announces
  • Nothing, by default. Content arriving as part of the initial page load is not a change to announce; it is the page loading.
  • A polite status message only for regions the user explicitly asked to load, and only once per region rather than once per chunk.
  • Loading state, if exposed at all, through aria-busy on the region rather than a live region that fires on every update (Live Regions and Announcement).

usually broken by The pattern invites wrapping every streamed region in aria-live="assertive" so that "the user knows it loaded". That converts a quiet, progressive page load into a sequence of interruptions that cut off whatever the user was reading — and it announces content they never asked for.

How to build it

Most important first.

  • Flush the <head> as early as you can. It is usually knowable before any data fetch, and it lets the browser start on the stylesheet and the fonts while the server is still working (The Critical Rendering Path).
  • Order the document by importance, not by page layout. What is needed to render meaningful content should be discoverable in the first chunk.
  • Do not await every data dependency before emitting anything. Emit the shell, then stream the slow regions as they resolve (Streaming Server Rendering).
  • Reserve space for anything that will stream in later. A skeleton with the same dimensions as the final content converts a layout shift into no shift at all (Visual Stability).
  • Verify streaming end to end, not in the framework. The only proof is the browser receiving bytes before the server finished; every hop between them can undo it (CDN Delivery).
  • Keep chunks meaningful rather than tiny. A flush per row of a table generates a rendering opportunity per row for no user benefit.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A partially loaded page is a normal state for a screen reader user, not an edge case. They may start reading the first heading while the rest is still arriving, so emitting content in a meaningful order matters more than emitting it fast.
  • Never move focus into streamed content that arrives after the user has started interacting. Focus stolen mid-sentence loses the reading position and is disorienting in a way a sighted user does not experience (Focus Management).
  • Content that arrives later should be announced only if the user asked for it. Wrapping a streamed region in an assertive live region turns every chunk into an interruption; a polite region, or no announcement at all for content the user has not requested, is usually right (Live Regions and Announcement).
  • Landmarks and headings should be in the first flush. They are the navigation structure; a document that streams its <nav> and <main> late is unnavigable for exactly as long as that takes (Document Structure and Reading Order).
  • Streaming makes progressive enhancement real: content in the HTML is available to assistive technology as it arrives, without waiting for JavaScript to parse, execute and hydrate (Client-Side Rendering).

What can go wrong

Failure modes
  • A reverse proxy or WAF buffering the full response to inspect it. Streaming works locally and disappears in production, with no error anywhere.
  • A framework that renders to a string. The API shape (renderToString versus a stream) decides this before any configuration does.
  • A Content-Length header being computed, which requires the whole body. Its presence in the response is a reliable tell that something buffered.
  • Late content that arrives without reserved space, shifting text a user is mid-sentence in — the specific harm streaming is supposed to avoid.
  • The mitigation failing: an early <head> flush that then has to be contradicted, because a data fetch determined the page title or a noindex. Some head content genuinely is not knowable early, and pretending otherwise produces a wrong <title> in the tab and in the history entry.
What can arrive out of order
  • Chunk boundaries are a network artefact, so any code observing the DOM during parsing sees a different intermediate state on every load. Nothing may depend on chunk shape.
  • Subresources discovered in an early chunk race with the rest of the document. A stylesheet from chunk one can finish after content from chunk three has already been parsed.
  • In out-of-order streaming, a late chunk's relocation script races the user: it may fire while someone is mid-scroll or mid-interaction in the placeholder region.
  • An aborted or truncated response produces a document that parsed successfully and is missing content, with no distinguishable signal from a document that was simply short.
Security
  • Response headers must be sent before the body, so anything header-borne is decided before the first flush. A Content-Security-Policy computed from data you fetch later is a policy you can no longer send (Content Security Policy).
  • Streaming templating makes escaping mistakes cheaper to reach: partial output is already committed and cannot be recalled if a later value turns out to need a different context. Escape at emit time, per context (Cross-Site Scripting).
  • A streamed error is not a status code. Once the response has begun with 200, a failure halfway through cannot become a 500; the client gets a truncated document that parses fine and is missing content.
  • Out-of-order streaming relies on inline scripts to relocate late chunks, which interacts directly with CSP: a strict policy needs a nonce or a hash on those, and the framework has to be told what it is (Content Security Policy).
Misreads
  • "Streaming makes the page load faster." It does not change total bytes or total time. It changes *when the browser can start*, which is what the user experiences.
  • "My framework supports streaming, so my page streams." Every hop between the framework and the browser can buffer. Test the bytes, not the API.
  • "Compression prevents streaming." Gzip and Brotli stream fine. Buffering is a server, proxy or CDN decision (Minification Is Not Compression).
  • "Nothing renders before DOMContentLoaded." Paint routinely happens well before parsing ends; DOMContentLoaded is about the parser and deferred scripts, not about pixels (`defer`, `async` and `type="module"`).
  • "Streaming and out-of-order rendering are the same thing." The parser is always in order. Out-of-order streaming is a trick built on top of it using templates and inline scripts.

Measuring it, and what changes in the field

How you would see this
  • The Network panel separates time to first byte from content download. A long TTFB with a short download is a buffering server; a short TTFB with a long download is streaming working (Reading a Network Waterfall).
  • curl -N against the URL shows bytes appearing over time in a terminal. It is the cheapest possible proof that something in the chain is buffering.
  • First contentful paint occurring well before the response finishes is the direct evidence that partial rendering happened (Vitals in the Field).
  • The Performance panel shows parse work interleaved with network activity as several segments rather than one block (A Mental Model of the Devtools).
  • Layout shift attribution names the element that moved when a chunk arrived, which is how you find the region that needed reserved space (Visual Stability).
Slow device, slow network, large data, old tab
  • On a high-latency, low-bandwidth connection streaming matters most: the gap between first byte and last byte is large, and everything the browser can do inside that gap is time the user does not spend waiting.
  • On a fast connection to a fast server the whole response may fit in one or two chunks, and streaming is worth approximately nothing. This is why it usually looks pointless in local development.
  • On a slow device the extra rendering opportunities cost real main-thread time, and very aggressive flushing can be a net loss (Long Tasks).
  • For a client-rendered app the initial HTML is a shell with nothing to stream, so this lesson applies to the shell only (Client-Side Rendering).
  • Behind a service worker that synthesises the response, streaming is whatever your worker code does — respondWith on a constructed Response can easily buffer where the network would not (Intercepting Fetch).
What this costs
  • Streaming commits you to the response status and headers before you know whether the page will succeed. Error handling moves into the body, which is strictly worse than a status code.
  • It constrains document order: the head has to be knowable first, so late-determined metadata such as a title from fetched data needs a different mechanism.
  • Out-of-order streaming adds inline scripts, <template> elements and a CSP consideration to what was a static document (Streaming Server Rendering).
  • More flushes means more rendering opportunities, each with a style and layout cost. The right chunk size is a judgement, not a setting.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALIncremental parsing and partial rendering are behaviours of every mainstream engine and follow from the specification's requirement that the parser process input as it becomes available. The heuristics for *when* to take a rendering opportunity are not specified and differ between Blink, Gecko and WebKit.
  • NETWORK-SPECIFICThe benefit scales with the gap between first and last byte. On a fast connection to a nearby origin the whole document may arrive in a single chunk and streaming changes nothing; on a high-latency mobile connection it can be the largest single improvement available.
  • FRAMEWORK-SPECIFICWhether streaming is even reachable depends on the API you call: a renderToString-style function buffers by definition, while a stream-returning renderer does not. React, Vue, Astro and SvelteKit all expose both shapes under different names, and the placeholder-and-relocate mechanism for out-of-order content differs in each.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

API Designstreaming-apis
Domains that do not exist yet
  • Compilers & Programming Languages — incremental and resumable parsing. An HTML tokenizer that can suspend mid-token at an arbitrary byte boundary is the same design problem as an incremental lexer in an editor front end, solved with the same explicit-state technique.
  • Testing & Reliability Engineering — a streamed response that fails halfway cannot change its status code. Testing partial-failure responses requires asserting on body content rather than on status, which most HTTP test helpers make awkward.