Async Rendering
A page is usually as slow as its slowest query. The handler waits for everything, the template renders once, and the reader sees nothing until the last dependency answers.
Async rendering breaks that coupling. The parts that are ready commit immediately, and each slow section replaces its own placeholder as its data settles — over one HTTP response, with no client-side data fetching.
Motivation
Section titled “Motivation”Consider a page with a profile you already have, an order list behind a 900 ms query, and a recommendation behind a 1500 ms call.
Rendered normally, the reader waits 1.5 seconds for a blank tab, then gets everything at once. Rendered asynchronously, the shell and the profile arrive in 20 ms, the orders at 0.9 s, and the recommendation at 1.5 s. The total is still 1.5 s, because the two dependencies overlap rather than queue — but the page became useful 75× earlier.
The important property is not the total. It is that the status code, the document head, and every settled value leave the server before the slow work finishes.
This is the streaming shape the Next.js App Router made familiar: a shell with placeholders goes out first, and each suspended region is filled in as its data resolves, over one response. Popcorn Web reaches the same result without a component framework in the browser — the page a reader receives is server-rendered HTML from first byte to last, and the only client code involved is one small module that moves finished markup into place. How it works at the end of this page describes the difference concretely.
What a handler changes
Section titled “What a handler changes”Almost nothing. Adopting this means passing pending values where you used to pass finished ones:
func profile(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pw.WriteHTML(w, r, Home(HomeParams{ Profile: Profile{Name: "Ada Lovelace", Joined: "2026-02-11"}, Orders: pw.Go(ctx, loadOrders), Recommendation: pw.Go(ctx, recommend), }))}There is no streaming API to call, no header to set, no flush to schedule, and
no loop to write. pw.WriteHTML asks the composed document whether it can open
an await boundary and picks its own path. A page without one keeps the ordinary
buffered response and its Content-Length.
Whether a response streams is therefore a property of the templates it composed, not a decision every handler repeats.
Creating a pending value
Section titled “Creating a pending value”pw.Go starts work in its own goroutine and hands back a handle:
func loadOrders(ctx context.Context) ([]Order, error) { return store.Orders(ctx, customerID)}
orders := pw.Go(ctx, loadOrders)The context you pass bounds the work and stays yours to cancel. A render bounds only how long it is willing to wait for it.
| Constructor | Use |
|---|---|
pw.Go(ctx, work) |
start the work now, in its own goroutine |
pw.Resolved(v) |
a value you already have, and tests |
pw.Failed(err) |
a failure you already know about |
Three properties are worth knowing.
A handle settles once and stays readable. A layout and the page inside it may hold the same value: both boundaries see the same result, and the work behind it runs once.
There is no channel constructor. A service that already returns a channel is
adopted by receiving from it inside the pw.Go closure, so every handle belongs
to a goroutine the framework started — and a panic in one becomes that handle’s
error instead of the process’s exit.
Starting early is the point. The work begins where you call pw.Go, so it
can overlap request parsing, authorization, and the rendering of everything
above it.
Declaring it in a template
Section titled “Declaring it in a template”Mark the parameter async and read it inside an await block:
package handlers
type Order { id: string total: string}
export component Home(profile: Profile, orders: async Order[]): html {<h1>{profile.name}</h1>
{await list = orders} <ul>{for order in list}<li>{order.id} — {order.total}</li>{/for}</ul>{fallback} <p class="pending">Loading orders…</p>{/await}}async T is a prefix modifier on any parameter or record field, and it becomes
pw.Pending[T] in the generated params struct. It is not callable, and the one
place it may be read is an await binding.
The modifier covers the whole type: async Order[] is a single pending slice,
not a slice of pending values. When each row has to arrive on its own, give the
row type its own async field and await it inside the loop.
A record may carry settled and pending members together — which is what lets the
example above render profile.name immediately while the orders are still in
flight.
The three clauses
Section titled “The three clauses”{await user = LoadUser(id), posts = LoadPosts(id)} ...primary subtree...{fallback} ...committed first, before anything is known...{recover err} ...rendered instead when the bindings fail...{/await}- Bindings after
awaitstart together. Two slow calls in one block take as long as the slower one, not their sum. fallbackis required. It is what commits to the response first, so a slow dependency never delays the rest of the page.recoveris optional and binds a safe error value withcode,message,retryable, andtimeoutfields.
Bindings are visible only in the primary subtree and the error name only in
recover, so no clause can read a value that does not exist when it renders.
A <slot> may not appear inside an await block — the fallback and the
replacement would both render it. This is also why boundaries from a document, a
layout, and a page are siblings rather than nested: they all start during the
first pass and settle concurrently.
When something fails
Section titled “When something fails”Whether a boundary declares recover decides what a failure costs.
With a recover clause, the failure is contained. That clause renders in
place of its own section and the rest of the page is untouched. The response
stays 200, which is honest — most of it worked.
Without one, the page is given up on. The template said what to show while waiting and what to show on success, and nothing about failure; leaving the fallback in place would make the page claim forever that it is still loading. The framework replaces everything below the document shell with an error page.
Register that page once:
pw.RegisterHTMLErrorPage(func(problem pw.Problem) pw.HTMLFragment { return Error500(Error500Params{Title: problem.Title})})It receives the mapped problem, never the original error, so a template cannot print a cause the server meant to keep. Without a registered resolver a minimal built-in page is used, so the escalation never depends on application setup.
Errors stay server-side
Section titled “Errors stay server-side”A recover subtree never sees a raw Go error. By default a failure becomes
code: "internal" with no message and a timeout becomes code: "timeout". To
publish something more specific, give the error its own safe projection:
func (e UpstreamError) PublicError() pw.AsyncError { return pw.AsyncError{Code: "upstream", Message: "Please try again.", Retryable: true}}Either way the original reaches the log, with the boundary that produced it:
ERROR await boundary failed with no recover clause boundary=tb-1 error="order service returned 503"Why an unhandled failure is still 200
Section titled “Why an unhandled failure is still 200”Because the status left with the shell, long before the failure was known. This is the honest cost of streaming, and it is worth knowing before you rely on status codes for monitoring these pages.
Turn streaming off and the same failure answers with a real 500: the render then fails while nothing is committed, so the response can still say so. Only one of the two paths can tell the truth in its status line, and it does.
Configuration
Section titled “Configuration”[html]streaming = trueasync_timeout = "3s"async_concurrency = 0bot_detection = truebot_async_timeout = "5s"bot_user_agents = []scriptless_detection = true| Key | Meaning |
|---|---|
streaming |
false forces the buffered path even when a page could stream |
async_timeout |
bounds one await boundary; 0 leaves the request context as the only deadline |
async_concurrency |
bounds simultaneous boundary work per render; 0 is unbounded |
bot_detection |
false streams to crawlers and CLI clients too — see below |
bot_async_timeout |
the boundary bound for a classified bot; 0 falls back to async_timeout |
bot_user_agents |
extra User-Agent substrings to treat as bots, appended to the built-in list |
scriptless_detection |
false leaves a browser with scripting disabled on the streamed response — see below |
An expired boundary renders recover with code: "timeout", or escalates if it
has none. Whether the work itself stops is up to the function: one that takes a
context.Context sees the cancellation, and one that does not is abandoned —
it finishes on its own and its result is discarded.
Setting streaming = false is the escape hatch for a proxy that buffers
responses. The same templates then render as one buffered response that blocks
until every boundary settles; the page is still correct and still complete.
What the browser does
Section titled “What the browser does”The document shell loads one small ES module that swaps each completion into place:
external RuntimeScriptURL(): url
export component Document(children: html?): html {<!doctype html><html><head>...<script type="module" src={RuntimeScriptURL()}></script></head><body><slot /></body></html>}func RuntimeScriptURL() *url.URL { return &url.URL{Path: pw.RuntimeScriptURL()} }The template calls a function rather than writing a literal path, because that
URL carries a revision derived from the script’s own bytes. An upgrade that
changes the runtime changes the URL without anyone editing a template, and the
response can claim Cache-Control: immutable honestly.
pw init scaffolds both halves, so a new project already has them.
No completion carries inline script, so script-src 'self' is enough — no
nonce, no unsafe-inline.
A browser with JavaScript disabled is asked to say so and then served the settled document, so it reads the page rather than the fallbacks — see Scripting turned off below. Treat streamed sections as an enhancement over content that is already meaningful all the same: that is what makes the fallback worth writing, and what the one extra round trip buys back.
Scripting turned off
Section titled “Scripting turned off”A browser with scripting disabled sends an ordinary User-Agent. Nothing in the
request says it will not run the runtime, so the classification below never sees
it — and a script cannot ask it anything, because not running scripts is the
whole point.
<noscript> is the one HTML feature that fires precisely when scripting is off,
so that is what asks. Popcorn Web 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 reaches the page they asked for,
complete, one round trip later and at the same path. A cookie remembers the
answer so only the first page of a visit pays for it.
It is on by default and costs a scripted browser nothing — the block never
fires for it, and no marker or cookie is ever set. Turn it off with
scriptless_detection = false if the extra round trip is not worth it for your
audience.
Three details are worth knowing. A client that refuses cookies as well gets a correct page every time, at two requests per page, because the marker parameter alone selects the buffered branch — there is no loop. A non-GET response is never asked, since a meta refresh re-issues a GET and would discard the validation errors it had just rendered. And a browser that blocks automatic refresh lands back on the streamed response with its fallbacks, which is exactly where it was before any of this existed.
Crawlers, spiders, and command-line clients
Section titled “Crawlers, spiders, and command-line clients”The runtime is what turns a fallback into content, so a client that never runs
it keeps every fallback. Googlebot, an OGP spider and curl cannot be asked the
way a browser can — they would follow the redirect or ignore it with equal
indifference — which makes the fallback the indexed text, the share card
description, and whatever lands in your terminal.
Popcorn Web recognises those clients by their User-Agent and hands them the
buffered response instead. They wait for every boundary and receive the finished
document:
$ curl -s https://example.com/orders | head -2<!doctype html><html><head>…</head><body><ul><li>A-1001 — $128.00</li>…This needs no second rendering path. streaming = false has always produced a
complete, correct page by blocking until every boundary settles; detection just
makes that choice per client instead of per deployment.
What counts as one
Section titled “What counts as one”Two rules, applied in order:
A known bot name. A curated list of agents that present a browser-shaped
User-Agent anyway — Googlebot, bingbot, GPTBot, ClaudeBot, PerplexityBot,
facebookexternalhit, Twitterbot, Slackbot, Discordbot, AhrefsBot, and the rest.
Anything not claiming to be Mozilla. Every mainstream browser still prefixes
Mozilla/5.0, for reasons that stopped being technical decades ago, and
effectively no CLI or client library copies the habit. curl/8.7.1,
Wget/1.21, python-requests/2.32, Go-http-client/1.1, okhttp/4.12 and
PostmanRuntime/7.42 are therefore all covered without appearing in any list —
including tools released after this one was written.
A missing User-Agent counts as a bot, because a browser always sends one.
The list matches specific names rather than the substring bot, since device
names contain it: an Android phone reporting CUBOT NOTE 20 is not a crawler.
Extend it when you need to:
[html]bot_user_agents = ["headlesschrome"]Headless browsers are deliberately absent by default — they run the runtime, so streaming already serves them correctly. Add one when a screenshot or PDF tool photographs the page before its boundaries settle.
It changes delivery, never content
Section titled “It changes delivery, never content”Both branches render one chain with one set of data, so what a crawler indexes is what a reader sees. The only difference is whether the fallbacks reach the wire first.
Keep it that way. Serving different content by User-Agent is cloaking, which
search engines penalise; serving the same content by a different mechanism is
the documented pattern. pw.IsBot(r) is available in a handler for things like
choosing a cheaper query or skipping an analytics beacon — not for changing what
a page says. Nothing verifies a User-Agent either, so it must never reach an
access decision.
Two consequences worth knowing
Section titled “Two consequences worth knowing”A crawler gets the truthful status. Nothing is committed on this branch, so
a boundary that fails with no recover clause answers a real 500 instead of
the 200 a streamed document swap has to keep.
A monitor or an indexer can act on that.
They wait longer for the first byte, so they get their own bound.
bot_async_timeout defaults to 5s — above the browser bound, because an indexer
waits far longer than a reader and a timeout fallback baked into a finished
document is exactly what this feature exists to prevent. It stays short because
a link preview spider abandons a slow response within a few seconds, and this
branch has no head start to offer it.
One header changes on any page that could stream: Vary: User-Agent. That URL
now has two byte representations, and a shared cache must not hand a streamed
body to a crawler. Pages with no await block are untouched and keep a cache
entry that varies on nothing.
Restrictions
Section titled “Restrictions”- An
asyncparameter may be read only inside anawaitbinding. - An
awaitblock requires afallbackclause. - A
<slot>may not appear inside anawaitblock. - A storing
@cachecomponent cannot declare anasyncparameter, or reach a record with one: stored bytes stand in for a fresh render, and a pending value belongs to the one request that started it. The form carrying nottlstores nothing, so a page that awaits may still use it to declare its scope.
Each of these is a generation error, so they surface from pw generate rather
than at request time.
A complete example
Section titled “A complete example”examples/async_render in the repository links one page per behaviour —
success, contained failure, and unhandled failure — so each path is reachable on
purpose rather than by luck.
When once is not enough
Section titled “When once is not enough”An await boundary settles once, so a screen rendered this way stops changing.
A source declared external live keeps delivering instead, and the same
boundary re-renders on its cadence for as long as the reader keeps the page
open — same clause, same fallback, same recover. See
Live Rendering.
How it works
Section titled “How it works”Nothing above requires knowing this. It is here because the mechanism is small enough to describe completely, and because one detail in it is easy to get wrong in a way that only fails in production.
The whole picture
Section titled “The whole picture”It is one HTTP response, sent with Transfer-Encoding: chunked. There is no
Content-Length, because how much HTML the boundaries will produce is not known
when the headers go out.
The first chunk is the entire document, in its placeholder state. Doctype,
head, body, every settled value, and every unresolved boundary rendered as a
placeholder holding its fallback — down to the closing </html>. It is complete
markup, so the browser lays the page out and the reader can start reading it.
Then one chunk per boundary, each written as its pw.Go finishes. They
arrive in settle order rather than document order: whichever query answers first
is sent first. Each chunk carries the <template data-tb-boundary="…"> block and
its marker, and the runtime moves it into the placeholder waiting for it.
The response ends when the last boundary has settled. Nothing more can be appended, and the connection closes.
Watching the same page arrive makes the shape obvious:
200 Transfer-Encoding: chunked Content-Length: (none)
0.02s +963 B <!doctype html> … <tb-boundary id="tb-1">…</tb-boundary> … </html> 0.90s +171 B <template data-tb-boundary="tb-1">…</template><tb-apply for="tb-1"> 1.50s +160 B <template data-tb-boundary="tb-2">…</template><tb-apply for="tb-2"> 1.50s + 0 B end of responseTwo things about that trace are worth noticing.
The completions arrive after </html>, which looks wrong and is not. A
parser that has seen the end of a document still appends what follows into the
body, which is exactly what makes this work without a second request.
And the connection is held open for the whole 1.5 s. That is the real cost of
this technique: a streamed request occupies a connection until its slowest
boundary settles, so async_timeout is a resource bound and not only a UX one.
The wire format
Section titled “The wire format”During the first pass every unresolved boundary is written as a placeholder holding its fallback, and the whole document is flushed:
<tb-boundary id="tb-1" style="display:contents"> <p class="pending">Loading orders…</p></tb-boundary>display:contents keeps the placeholder out of layout, so a boundary cannot
change how its fallback or its replacement is positioned.
Each completion is then appended to the same response, in settle order, as an inert template followed by a marker element:
<template data-tb-boundary="tb-1">…resolved…</template><tb-apply for="tb-1"></tb-apply><tb-apply> is a custom element. Its connectedCallback runs while the document
is still parsing, so the swap is as prompt as an inline script would be: it reads
the boundary id from for, replaces the element with that id, and removes both
the template and itself. An applied boundary therefore leaves no placeholder,
no template, and no marker in the DOM — nothing accumulates, and nothing can
be applied twice.
Why the marker, and not the template
Section titled “Why the marker, and not the template”An HTML parser inserts an element when it reads its start tag. Code that
reacted to the <template> appearing could therefore read a template whose
content had not arrived yet, replace the placeholder with nothing, and remove the
template — destroying the fallback along with the result.
Because <tb-apply> comes after </template> in the byte stream, the template
is guaranteed complete by the time the marker exists, however the bytes were
chunked.
This is not hypothetical, and it is invisible in development: a small completion
arrives in one chunk and parses in one task. It appears only once a proxy, a TLS
record boundary, or a compressing encoder splits the bytes. Any conforming
runtime must trigger on the marker — a MutationObserver watching for the marker
is fine, one watching for the template is not.
The same discipline covers the whole-page replacement an unhandled failure produces, which uses its own envelope and is terminal:
<template data-tb-document>…error page…</template><tb-apply-document></tb-apply-document>Compared with the Next.js App Router
Section titled “Compared with the Next.js App Router”The user-visible behaviour is the same. What differs is how much of the browser’s work is JavaScript’s.
React’s streaming SSR drives its swaps with inline <script> elements embedded
in the stream, and the server component payload is delivered the same way. That
is what makes a CSP nonce part of the setup, and it arrives alongside a client
runtime that also hydrates the tree so components can re-render in the browser.
Popcorn Web carries no script in the stream at all. A completion is markup, the
trigger is an element definition, and the runtime is a single cached module
loaded by src — so script-src 'self' is sufficient, with no nonce and no
unsafe-inline. There is no hydration, no virtual DOM, and no component code in
the browser.
The trade is deliberate: this mechanism can place server-rendered HTML, and nothing else. It will not re-render a section from client state, and it gives you no interactivity by itself. Interactivity remains something you add where you want it, rather than the price of streaming.
