Marko Streaming & the Tags API

Most islands frameworks ask you to draw the hydration boundary by hand: a directive, a file-system convention, or a wrapper component tells the build which subtrees ship JavaScript. Marko takes the opposite stance. Its compiler analyses your templates, decides which components are actually interactive, and streams the rest as inert HTML — no annotations required. Combined with an SSR runtime that flushes markup the instant it is ready and reorders slow fragments as they resolve, Marko delivers component-level streaming SSR and automatic partial hydration as defaults rather than opt-ins. This page explains how the streaming engine and the Tags API fit together, how to wire them into a real project, and where the sharp edges are. It sits within the broader Framework-Specific Islands & Streaming SSR strategies for shipping less JavaScript.


Marko Out-of-Order Streaming & the Reorder Runtime The server flushes the static shell and an inline placeholder first, keeps rendering a slow await fragment, then flushes the resolved fragment inside a hidden template near the end of the stream. A small reorder script on the client moves the fragment into the placeholder slot without blocking earlier content. SERVER — RESPONSE STREAM CLIENT — DOM Flush 1 — shell + header (t=0ms) <main>…static content…</main> Flush 2 — await placeholder <div id="rv$">Loading reviews…</div> promise still pending — stream not blocked Flush 3 — footer + more static content Flush 4 — resolved fragment (t=380ms) <template id="rv$!">…reviews…</template> <script>$reorder("rv$")</script> Shell painted immediately FCP + LCP unblocked by slow data Placeholder in original slot #rv$ reserves layout height Reorder runtime swaps fragment in placeholder.replaceWith(template.content) Compiler-decided hydration split (no manual directives) Stateful component <let/count=0> → ships JS chunk serialized + resumed on client Static component no state / no handlers → 0 KB JS emitted as inert HTML

Concept Definition & Scope

Marko is a compiler-first UI language. A .marko file is not JavaScript with template extensions; it is a template language that the Marko compiler analyses statically and lowers into two separate outputs — a server render function and, only where interactivity demands it, a client runtime chunk. This is the crucial difference from directive-based islands frameworks such as Astro islands and client directives, where you tell the build what to hydrate. In Marko, the build tells you.

Two capabilities anchor this guide, and they reinforce each other:

  • Component-level streaming. Marko’s SSR runtime writes HTML to the response as soon as each part of the tree is ready. A slow section does not hold back a fast one, because Marko can flush the surrounding shell, emit a placeholder, and backfill the slow fragment later in the same response. This is genuine out-of-order streaming, not merely chunked transfer of an already-complete document.
  • The Tags API. Marko’s authoring model expresses reactive state, derived values, references, and lifecycle as tags with tag variables. Because these constructs are visible to the compiler, Marko can decide — per component — whether any client JavaScript is needed at all. A component that only interpolates props into markup compiles to zero bytes of browser code; a component that declares <let/count=0> and an event handler compiles to a small, self-contained chunk.

The scope of this page is the mechanics that make the two work together: how the Tags API expresses a hydration boundary implicitly, how the <await> tag and the reorder runtime stream out of order, and how automatic stateful-component detection drives bundle splitting. For the step-by-step recipe focused purely on flushing slow content ahead of fast content, see streaming out-of-order HTML with Marko.

Technical Mechanics

The Tags API as an implicit hydration boundary

In the Tags API, state is a tag variable. You declare reactive state with <let>, derived state with <const>, and you attach handlers as attributes. The tag variable name (the part after the slash) is what the rest of the template references.

// counter.marko — a stateful component.
//  declares reactive state. Because the compiler sees a mutable
// tag variable that is read in markup AND written in an event handler, it
// classifies this component as interactive and emits a client chunk for it.



<div class="counter">
  // Interpolations are reactive: when `count` changes on the client, only the
  // affected text nodes update — Marko does not re-run the whole component.
  Count: ${count} (${isEven ? "even" : "odd"})

  // The onClick handler mutates state, so this button's subtree is hydrated.
  <button onClick() { count++ }>Increment</button>
</div>

Contrast that with a component that never mutates state:

// price-tag.marko — a purely presentational component.
// It reads `input` (props) but declares no , registers no handlers,
// and runs no lifecycle. The compiler emits ZERO client JavaScript for it;
// the output is inert HTML that is part of the static shell.


${formatted}

Nothing in price-tag.marko opts out of hydration — there is simply nothing to hydrate, and the compiler proves it. This is what “automatic” means: the island directive is inferred from the shape of the code rather than declared. Lifecycle work, when you need it, is also a tag:

// chart-island.marko — lifecycle as a tag.


Chart renders here after mount</div>

Streaming with the <await> tag

The <await> tag is where component-level streaming becomes concrete. It takes a promise, renders a placeholder while the promise is pending, and renders the resolved value once it settles — all without blocking the bytes around it.

// reviews-section.marko
// `input.reviewsPromise` is created on the server (e.g. a DB query) and passed
// in WITHOUT awaiting it, so the parent template keeps rendering and flushing.
<section class="reviews">
  <h2>Customer reviews</h2>

  
    // @then receives the resolved value. This subtree is written to the stream
    // only after the promise settles — potentially long after the shell flushed.
    <@then|reviews|>
      
        
      </for>
    

    // @placeholder flushes IMMEDIATELY, in the element's real DOM position,
    // reserving layout space so the later swap does not shift the page.
    <@placeholder>
      <p class="reviews-skeleton" style="min-height: 240px">Loading reviews…</p>
    

    // @catch keeps a rejected promise from tearing down the whole response.
    <@catch|err|>
      <p role="alert">Reviews are temporarily unavailable.</p>
    
  </await>
</section>

The client-reorder attribute is the switch that turns this into out-of-order streaming. Without it, Marko will still stream, but it holds bytes that come after the <await> until the promise resolves (in-order streaming). With it, Marko flushes the placeholder in position, continues rendering and flushing everything after the <await>, and then — when the promise resolves — writes the resolved fragment near the end of the response inside a <template>, followed by a minimal inline script that moves it into the placeholder’s slot. The full recipe, including nested awaits and verification, lives in streaming out-of-order HTML with Marko.

Automatic detection and bundle splitting

The compiler’s classification pass walks each component and asks a single question: does this subtree need to exist on the client? A component needs client code if it mutates a tag variable in an event handler, declares onMount/onDestroy lifecycle, uses a browser-only API, or renders a child that does. Everything else is server-only.

The consequence for bundle splitting is that Marko’s chunks map to interactive regions, not to routes or files. Two interactive components on the same page produce two independently loadable chunks; a page of a hundred static components and one counter ships only the counter’s code. This is the same outcome you would hand-build with a manual scheduler and dynamic imports — as described in Core Islands Architecture & Hydration Models — except Marko derives the split from static analysis instead of from annotations you maintain by hand.

Marko vs Manual Islands

The table below compares Marko’s compiler-driven model against a hand-rolled islands setup (a static template plus a client scheduler that reads data-* markers), and against directive-based frameworks for reference.

Dimension Marko (Tags API + streaming) Manual islands (custom scheduler) Directive-based (Astro / Next use client)
Hydration boundary Inferred by compiler from state/handler/lifecycle usage Declared per node via data-hydrate markers you write Declared via client:* or 'use client' directives
Bundle splitting Automatic, per interactive region Manual — one dynamic import per island you register Automatic per directive-marked component
Out-of-order streaming Built in via <await client-reorder> + reorder runtime Hand-built with placeholders + a swap script Suspense (Next) / server islands (Astro)
Static components cost 0 KB — proven inert at compile time 0 KB if you remember not to register them 0 KB unless a directive is added
Reactivity granularity Fine-grained: only affected text/attrs update Whatever your mount function re-renders Component-level re-render on hydration
Risk of over-hydration Low — compiler is the source of truth High — easy to over-mark islands and inflate TBT Medium — a stray client:load hydrates a subtree
Authoring surface One .marko language JS + markup + marker conventions Framework component + directive syntax

The decisive column is risk of over-hydration. In a manual or directive model, over-fragmentation and over-eager hydration are the two most common ways Total Blocking Time regresses after a migration to islands. Marko removes the human decision from that loop.

Step-by-Step Integration

  1. Scaffold a Marko project with the run tooling. Use Marko’s project setup so the compiler is wired into your bundler and an SSR server is generated. This is what emits the split server/client outputs and the streaming runtime; a bare compiler invocation will not give you flushing.

  2. Author your page as a tree of .marko components. Keep presentational components free of <let>, handlers, and lifecycle so they stay inert. Push interactivity down into small leaf components (counter.marko, chart-island.marko) so the compiler’s split is tight and each client chunk stays small.

  3. Create data promises on the server without awaiting them. In your page component, start the slow query and pass the promise into an <await> tag. Awaiting before render is the single most common mistake — it collapses streaming back into a blocking render.

// product-page.marko
// Kick off the query but DO NOT await — pass the pending promise down so the
// shell can flush while the query runs.




  1. Add client-reorder to any <await> whose data is genuinely slow. Fast queries (sub-50ms) do not benefit and add a tiny reorder script for no gain; reserve out-of-order flushing for third-party calls, aggregation queries, and personalised data.

  2. Reserve layout space in every placeholder. Give the @placeholder subtree a min-height (or an aspect-ratio box) equal to the expected resolved height so the later swap does not cause a layout shift — the streaming analogue of the CLS failure mode covered in fallback UI and skeleton strategies.

  3. Build and inspect the split. Run the production build and confirm that your static components produced no client chunk and each interactive leaf produced its own. This is the validation loop that keeps the automatic split honest as the codebase grows.

Measurement & Validation

Marko’s promises are testable, and you should treat the two claims — “static components ship no JS” and “slow content streams out of order” — as separate assertions with separate checks.

Confirm the hydration split from the build output. After a production build, the client manifest lists exactly the chunks that will be requested. A static page should reference none; a page with one counter should reference one small chunk. Wire this into CI as a byte-budget assertion:

// scripts/assert-island-budget.mjs — fails the build if a supposedly static
// route starts shipping client JavaScript.
import { readFileSync } from "node:fs";

const manifest = JSON.parse(readFileSync("./dist/manifest.json", "utf8"));
const routeJs = manifest["routes/marketing/index"]?.clientEntry ?? null;

if (routeJs) {
  console.error(`[budget] marketing route unexpectedly ships JS: ${routeJs}`);
  process.exit(1);
}
console.log("[budget] marketing route is inert — 0 client chunks. OK.");

Confirm out-of-order flushing on the wire. The most direct evidence is the raw response byte order. curl --no-buffer shows you the placeholder arriving early and the <template> fragment arriving late, with the reorder <script> between them:

# The placeholder line appears near the top of the stream; the resolved
# <template id="…!"> and its reorder <script> appear seconds later — proof
# the shell was not blocked on the slow promise.
curl --no-buffer -s http://localhost:3000/product/42 | grep -n -E 'Loading reviews|<template id|<script>\$'

Confirm the user-visible payoff in DevTools. Record a Performance trace under a 4G / CPU 4× profile. First Contentful Paint should land on the shell, well before the reviews fragment appears; the flame chart should show the reorder script as a sub-millisecond task, not a long task. Cross-check that the placeholder’s reserved height prevented a Layout Shift entry when the fragment swapped in. These are the same interactivity metrics you would track for any islands implementation — Marko simply moves the levers into the compiler.

Failure Modes

1. Awaiting the promise before render (streaming collapses to blocking). If you write <const/reviews=await loadReviews(id) /> at the top of a component, the render cannot proceed until the query resolves, so nothing flushes early and client-reorder has no effect. The symptom is a Time to First Byte that tracks your slowest query.

// WRONG — the whole render blocks on the query.



// RIGHT — pass the pending promise into <await> and let it stream.

  <@then|reviews|>
  <@placeholder><p style="min-height:240px">Loading…</p>
</await>

2. Accidental hydration from an unused handler. Registering an onClick (or any handler) on a component that does not otherwise need state still flips the compiler’s classification to interactive and ships a chunk. If a “static” component starts appearing in your client manifest, grep it for stray handlers or browser-API calls and move genuinely interactive behaviour into a dedicated leaf component so the static parent stays inert.

3. Placeholder without reserved height (streaming CLS). An @placeholder that collapses to a few pixels lets the resolved fragment expand the layout when it swaps in, producing a Cumulative Layout Shift exactly at the moment the user is reading. The fix is always the same: size the placeholder to the expected content height with min-height or an intrinsic-size box, mirroring the skeleton placeholder strategy used across islands frameworks.


← Back to Framework-Specific Islands & Streaming SSR