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.

Marko Out-of-Order Flush Timeline A horizontal timeline. The server flush lane shows: shell and placeholder at t=0, footer at t=1, and the resolved reviews template plus reorder script at t=380ms. The client lane shows the shell painting immediately, the placeholder holding layout, and the fragment swapping into place when the reorder script runs. t=0 t≈1ms t≈380ms SERVER FLUSHES shell + placeholder #rv$ footer (static) resolved fragment <template> + $reorder CLIENT DOM shell painted FCP — not blocked fragment swapped in replaceWith(content)

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>
    

    // @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>
    

    // @catch prevents a rejected promise from failing the whole response.
    <@catch|err|>
      <p role="alert">Reviews are temporarily unavailable.</p>
    
  </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>
  
  <@placeholder>
    <p class="skeleton" style="min-height: 240px">Loading reviews…</p>
  
  <@catch|err|>
    <p role="alert">Reviews are temporarily unavailable.</p>
  
</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|>
  <@placeholder><div style="min-height:160px">Loading stats…</div>
</await>


  <@then|f|>
  <@placeholder><div style="min-height:320px">Loading activity…</div>
</await>


  <@then|b|>
  <@placeholder><div style="min-height:120px">Loading billing…</div>
</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.


What Arrives on the Wire, in Order Four segments of the response body in arrival order. First the placeholder element with a stable id. Then unrelated later content, proving the stream was never blocked. Then a hidden template element carrying the resolved fragment. Finally a one-line script that moves the template's content into the placeholder. Each segment is annotated with its arrival time. The response body, segment by segment t = 40 ms · placeholder with a stable id, sized to the final content the visitor can already read the page around it t = 60 ms · footer, related links, everything that did not depend on the slow call this is the segment that would have been blocked by a buffered response t = 380 ms · hidden template carrying the resolved fragment inert until moved — parsing it costs nothing visible t = 380 ms · inline reorder call — one DOM move, no framework involved

Verification

Confirm out-of-order streaming with evidence, not by eyeballing the rendered page (which looks the same either way once everything loads):

  1. 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 footer prints before the reviews <template>, out-of-order streaming is working. If it prints after, you are still streaming in order — recheck the client-reorder attribute.

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

  3. 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-height is smaller than the resolved content.

  4. 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|>…
  <@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|>…
  <@placeholder>…
  <@catch|err|><p role="alert">Reviews unavailable.</p>
</await>

Where Out-of-Order Streaming Breaks Three failure rows. A fragment that never appears traces to a proxy stripping or buffering the trailing script. Content appearing in the wrong slot traces to duplicate placeholder ids on a page rendering the same component twice. A layout jump on swap traces to a placeholder whose reserved height did not match the resolved fragment. FAILURE LAYER AND FIX fragment never appears in production proxy buffering or a CSP blocking the inline reorder script content lands in the wrong slot duplicate placeholder ids — derive the id from the record, not the component page jumps when the fragment lands the placeholder reserved the wrong height — measure the real fragment once

Frequently Asked Questions

What is the difference between in-order and out-of-order streaming in Marko?

In-order streaming holds the bytes after an unresolved await until it settles, so content is delivered in source order but a slow fragment stalls everything below it. Out-of-order streaming, enabled with client-reorder, flushes a placeholder in position, continues with the rest of the page, and backfills the resolved fragment later via a small reorder script.

Why does my Marko page still block on a slow query?

The most common cause is awaiting the promise before render — for example assigning await loadData() to a const at the top of a component. That forces the render to wait. Pass the pending promise into an await tag instead, and add client-reorder so the surrounding content flushes independently.

Does out-of-order streaming cause layout shift?

It can, if the placeholder collapses to a smaller height than the resolved content. Reserve the expected height on the placeholder with min-height or an aspect-ratio box so that when the reorder runtime swaps the fragment in, the surrounding layout does not move.

← Back to Marko Streaming & the Tags API