Skip to content

Incremental HTML updates

Performance is about the work a request costs the server. This page is about the two costs the server never sees: the bytes that go on the wire, and the time a reader spends looking at nothing.

They are separate problems. A page can be cheap to render and still take two seconds to become useful, because one query on it is slow. A page can render in microseconds and still re-send a navigation bar the browser has had open for twenty minutes. Popcorn Web has one mechanism for each, plus a third for the case where the server is the one that learns something new.

A static file is the floor everyone measures a web framework against. Nothing to compute, cache it forever, serve it from an edge. It is hard to argue with.

It is also, on the second page view, strictly worse than what this framework sends.

A static site second page view Popcorn Web second page view layout, navigation, footer — already open in the browser changed changed The static server cannot send less. It has no idea what this particular browser is already holding. A page that knows sends the difference, and still serves a plain document to anything that asks.

That is the whole argument, and it is worth being precise about why it holds. A static server answers every request identically because it has to: two browsers asking for /orders?page=2 get the same bytes, and one of them may have arrived from /orders?page=1 while the other opened a bookmark. Sending the difference would require knowing which. Popcorn Web knows, because the browser tells it in the request, and the answer is the changed region rather than the document.

The catch a claim like this usually hides is that you have paid for it somewhere else — a client-side router, a hydration pass, a build step, a page that shows nothing until a bundle loads. There is none of that here. What reaches the browser is server-rendered HTML from first byte to last, and the one script involved moves finished markup into place. Turn it off and you are back to the static-file behavior you started with, which is covered in full below.

The three differ in when the server knows something the browser does not, and that is the only question you need to pick between them.

Async rendering one response Partial updates two requests Live rendering one open connection shell region region full page delta update update update 0 1s 2s 3s 4s illustrative time →

The times are illustrative, but the events are the distinction. A dashed tick is the browser asking. Async rendering asks once and receives one response in parts. Partial updates ask again and receive a much smaller second answer. Live rendering asks once, keeps the pale connection open, and receives a mark only when the server has something new.

The rule for choosing between the second and the third is about the URL, not about the mechanism. State that can live in the URL belongs there — a sort order, a page number, a filter — because that makes the page shareable and back-navigable, and partial updates handle it with no code. Reach for live rendering when there is no request to attach the new information to, because what changed happened on the server while nobody was typing.

Async rendering — making the first view early

Section titled “Async rendering — making the first view early”

A page is usually as slow as its slowest query. The handler waits for everything, the template renders once, and the reader gets a blank tab until the last dependency answers.

An {await} block breaks that coupling. The section renders a fallback immediately and the response commits with it; when the value settles, the finished markup is written into the same response and takes the fallback’s place.

one HTTP response 200 · <head> · the shell that does not wait <div id="orders"> loading… </div> <div id="recs"> loading… </div> <template for="orders"> … </template> <template for="recs"> … </template> 20 ms 0.9 s 1.5 s The two dependencies run concurrently, so the response ends at 1.5 s rather than 2.4 s. But it became readable at 20 ms.

The important property is not the total. It is that the status code, the head, and every value that was ready leave the server before the slow work finishes. Nothing about the handler changes to get this: whether a response streams is a property of the templates it composed.

Async Rendering covers pw.Go, the timeouts, and what a failed boundary does to a response that has already committed.

Async rendering delivers a slow section once. A chat log, a metrics panel and a notification feed want the opposite: the server learns something, and a region of a page somebody is already looking at should say so.

The alternative most applications reach for is polling, and its cost is not the requests — it is that almost all of them find nothing.

Polling Live rendering eight requests, five of which found nothing the update still waited for the next tick one connection, written the moment there is something nothing travels in between

A live source is declared external live and bound in the same {await} clause an async value is, so a source that changes from async to live changes no template that calls it. The handler changes nothing at all, and the browser holds one connection back to the page’s own URL for as long as the screen is open.

Live Rendering covers the source signature, reconnection, and what happens when a subscription outlives its data.

Partial updates — every view after the first

Section titled “Partial updates — every view after the first”

Every layout and page of a rendered chain is already an update boundary, with an identity and a digest of what it rendered written onto it by generation. A request arriving with the old digests can be answered with the difference.

An ordinary component is deliberately not a boundary, and that decision is what keeps the mechanism cheap.

the document shell — never a boundary, because a delta reuses the one on screen layout.pw.html — a boundary site navigation an ordinary component search.pw.html — a boundary, and the only one that changed a 500-row result list — one component, not 500 addresses Only the outermost changed boundary travels. Everything above it matched the digests the request carried, and everything inside it came along. Making every component a boundary would put five hundred entries in every request, which is why an ordinary component is not one.

The runtime intercepts a same-origin link or a GET form, re-requests the page’s own URL, and applies what comes back — so a search form refines the page it is on with no application JavaScript at all. Nothing is written on either side; this is what enabled = true buys on its own.

Partial Updates covers the two other paths — redrawing one component, and answering a mutation with the regions it changed — along with the validator key that enabling this requires.

Here the three genuinely differ, and rounding that off to “it degrades gracefully” would cost somebody an afternoon.

Async rendering Live rendering Partial updates the settled document, at the same path one extra round trip on the first page, and the progressive delivery a real snapshot, and then it stops no non-script way for a server to push exists, so the updating is what is lost nothing at all is lost links navigate, GET forms submit, back works — this is the path that was always there

Partial updates are the outlier, and the reason is structural rather than careful. There is no fallback implementation to maintain, because the runtime never replaced anything: a link is an <a href> and a filter is a <form method="get">, and the runtime is an optimization of what that markup already does by itself. A request that carries no update header is answered with the ordinary document, so a crawler, curl, and a browser with scripting disabled are all unaffected. That is also the standard the intercepting half is held to — every gesture it takes over has to reach the destination the browser would have reached on its own.

Async rendering has to ask, because a browser with scripting off is not a crawler and sends nothing that says so. <noscript> is the one HTML feature that fires precisely when scripting is off, so the framework contributes a block to the head of a streamed page that redirects to that same page under a marker parameter — and the marked request renders buffered. The reader lands on the page they asked for, complete, at the same path. What they give up is the progressive delivery and one round trip on the first page of a visit; a cookie remembers the answer for the rest of it, and a scripted browser never sees any of it. Turn it off with scriptless_detection = false, or take the older site-wide answer with streaming = false, which gives up the early paint for everybody.

Live rendering is the one that cannot be rescued, and it should not pretend otherwise: delivering without being asked is the feature. It is not nothing, though. The buffered branch renders a live boundary from its first delivered value, so a scriptless reader gets a real snapshot rather than a fallback that never resolves.

A page that reloads in a hundred milliseconds needs none of it. Each mechanism adds something real — a browser runtime on every document, a secret to deploy, a rule that every re-render must be free of side effects, a held-open connection per open screen — and a reload nobody notices is not a cost worth paying to remove.

Reach for async rendering when one section is measurably slower than the rest of the page, not when the page as a whole is slow; if everything is slow, the fix is in the queries. Reach for partial updates when a screen is refreshed often enough that the reload is the thing people complain about. Reach for live rendering when the alternative is polling that mostly finds nothing.

And when the application wants to own the swapping itself, none of these is the right shape — Fragments and islands renders one template with no negotiation and no ordering guarantee, and hands it to whatever library you chose.