Skip to content

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.

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.

shell + fallbacks orders recommendation delivered waiting on the query waiting on the call readable 0.5s 1.0s 1.5s Both dependencies overlap, so the total is 1.5s rather than 2.4s.

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.

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.

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.

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.

{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 await start together. Two slow calls in one block take as long as the slower one, not their sum.
  • fallback is required. It is what commits to the response first, so a slow dependency never delays the rest of the page.
  • recover is optional and binds a safe error value with code, message, retryable, and timeout fields.

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.

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.

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"

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.

[html]
streaming = true
async_timeout = "3s"
async_concurrency = 0
bot_detection = true
bot_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.

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>
}
templates/templates.go
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.

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.

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.

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.

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.

  • An async parameter may be read only inside an await binding.
  • An await block requires a fallback clause.
  • A <slot> may not appear inside an await block.
  • A storing @cache component cannot declare an async parameter, 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 no ttl stores 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.

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.

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.

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.

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 response

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

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.

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>

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.