Streaming Out-of-Order HTML with Marko
A single slow data source — a reviews aggregation, a personalised recommendation call, a third-party inventory check — should never hold back the rest of a page. Marko can flush the fast, static shell immediately and stream the slow fragment into place the moment it resolves, keeping First Contentful Paint independent of your slowest query. This guide is the hands-on companion to Marko Streaming & the Tags API: it walks through wiring <await>, the client-reorder attribute, and Marko’s reorder runtime, then verifying that content really is leaving the server out of order.
Prerequisites
The Out-of-Order Flush Timeline
Before writing code, fix the sequence of events in your head. The diagram below maps what leaves the server, in what order, and how the client reassembles it.
Implementation Steps
Step 1 — Create pending promises on the server
Goal: Start the slow query but keep the render moving, so the shell can flush before the data is ready.
The single rule that makes streaming work: do not await the slow data before render. Create the promise and pass it down as a value.
// product-page.marko
// loadReviews() returns a Promise. We assign the PENDING promise to a const and
// pass it into the child — no await here, so rendering continues immediately.
// fast, static — flushes at once
// also flushes without waiting
Expected output: Nothing observable yet, but the render function for product-page.marko returns without blocking on loadReviews. If you add a console.log after the <const> line, it logs before the query resolves.
Step 2 — Wrap the slow content in an <await> with a sized placeholder
Goal: Give Marko a placeholder to flush now and a resolved branch to flush later, without shifting layout when they swap.
// reviews-section.marko
<section class="reviews">
<h2>Customer reviews</h2>
// @then renders once the promise resolves — streamed later.
<@then|reviews|>
<ul>
<li>${r.author}: ${r.body}</li>
</for>
</ul>
@then>
// @placeholder flushes immediately. The min-height reserves the space the
// resolved list will occupy, so the later swap causes no layout shift.
<@placeholder>
<p class="skeleton" style="min-height: 240px">Loading reviews…</p>
@placeholder>
// @catch prevents a rejected promise from failing the whole response.
<@catch|err|>
<p role="alert">Reviews are temporarily unavailable.</p>
@catch>
</await>
</section>
Expected output: The page now renders end-to-end with Loading reviews… in place. At this point streaming is still in order — the footer waits behind the reviews. You will fix that in the next step.
Step 3 — Enable out-of-order flushing with client-reorder
Goal: Flush everything after the <await> without waiting, and backfill the reviews when they arrive.
// reviews-section.marko — the ONLY change is the client-reorder attribute.
<@then|reviews|>
<ul>
<li>${r.author}: ${r.body}</li></for>
</ul>
@then>
<@placeholder>
<p class="skeleton" style="min-height: 240px">Loading reviews…</p>
@placeholder>
<@catch|err|>
<p role="alert">Reviews are temporarily unavailable.</p>
@catch>
</await>
With client-reorder, Marko writes the placeholder in position, continues rendering and flushing the footer, and — once reviewsPromise resolves — emits the resolved list inside a <template> near the end of the response, followed by a tiny inline script that relocates it into the placeholder’s slot.
Expected output: In the browser, the footer is interactive and painted while Loading reviews… is still showing; the review list then pops into place without moving the footer. You will confirm the byte order objectively in Verification.
Step 4 — Order-independent siblings and nested awaits
Goal: Let multiple slow regions each resolve on their own schedule rather than serialising.
Each <await client-reorder> is independent, so a page can have several slow regions that resolve in any order. Keep each promise separate — do not Promise.all them, or you reintroduce a single slowest-wins bottleneck.
// dashboard.marko — three panels, three independent flushes.
<@then|s|> @then>
<@placeholder><div style="min-height:160px">Loading stats…</div>@placeholder>
</await>
<@then|f|> @then>
<@placeholder><div style="min-height:320px">Loading activity…</div>@placeholder>
</await>
<@then|b|> @then>
<@placeholder><div style="min-height:120px">Loading billing…</div>@placeholder>
</await>
Expected output: Whichever query resolves first appears first, regardless of source order. On the wire you will see three <template> fragments arriving at three different times.
Verification
Confirm out-of-order streaming with evidence, not by eyeballing the rendered page (which looks the same either way once everything loads):
-
Raw byte order. Stream the response unbuffered and watch the placeholder arrive before the resolved fragment:
# The "Loading reviews…" line prints near the top; the resolved <template ...!> # and its reorder <script> print seconds later — proof the shell was not blocked. curl --no-buffer -s http://localhost:3000/product/42 \ | grep -n -E 'Loading reviews|<template id|footer'If
footerprints before the reviews<template>, out-of-order streaming is working. If it prints after, you are still streaming in order — recheck theclient-reorderattribute. -
Time to First Byte independence. Artificially raise the reviews query latency (add a 2s delay to the stub) and confirm TTFB and FCP do not move. In DevTools → Network, the document’s first byte should arrive on the same timeline as before; only the reviews fragment’s arrival slides later.
-
No layout shift on swap. In DevTools → Performance, record the load and check for a Layout Shift entry at the moment the fragment appears. A correctly sized placeholder produces zero shift; if you see one, the placeholder
min-heightis smaller than the resolved content. -
Reorder script is trivial. The injected reorder script should register as a sub-millisecond task in the flame chart — it is a DOM move, not a hydration. A long task here means something else (an interactive island in the fragment) is hydrating and should be measured separately.
Troubleshooting
The footer waits for reviews — content still streams in order
Root cause: The <await> tag is missing client-reorder, so Marko uses in-order streaming and holds every byte after the await until the promise resolves.
Fix: Add client-reorder to the <await> tag. Confirm it is on the <await> itself, not on a child <@then>/<@placeholder> — the attribute belongs to the await.
<@then|reviews|>…@then>
<@placeholder>…@placeholder>
</await>
TTFB tracks the slow query even with client-reorder set
Root cause: The promise is being awaited before render — usually <const/reviews=await loadReviews(id) /> at the top of a component, which blocks the render function itself. Out-of-order flushing cannot help because there is nothing to flush until the await settles.
Fix: Pass the pending promise into the <await> tag and let the tag resolve it. Never await slow data in the render body.
// WRONG
// RIGHT
…</await>
The page jumps when reviews appear (Cumulative Layout Shift)
Root cause: The @placeholder collapses to a smaller height than the resolved @then content, so the reorder swap expands the layout and pushes surrounding content down.
Fix: Reserve the expected height on the placeholder with min-height or an aspect-ratio box that matches the resolved content. This is the streaming form of the skeleton placeholder strategy; size the skeleton to the real content, not to a thin loading line.
A rejected promise tears down the whole response
Root cause: The <await> has no @catch, so a rejection propagates up and aborts the stream after the shell has already flushed — leaving a truncated page.
Fix: Always provide a @catch branch that renders a graceful fallback. Because the shell has already been sent, the catch content streams into the placeholder slot just like a successful resolution would.
<@then|reviews|>…@then>
<@placeholder>…@placeholder>
<@catch|err|><p role="alert">Reviews unavailable.</p>@catch>
</await>
Related
- Marko Streaming & the Tags API — the parent guide covering the Tags API, automatic hydration detection, and how
<await>fits the wider streaming model. - Next.js App Router Streaming Patterns — the Suspense-based equivalent of out-of-order flushing for comparison.
- Fallback UI and Skeleton Strategies — sizing placeholders so streamed fragments swap in without layout shift.
← Back to Marko Streaming & the Tags API