DEV Community

Cover image for This Framework Was Streaming HTML Before It Was Cool. Learn It in 135 Browser Lessons
defunkt-dev
defunkt-dev

Posted on

This Framework Was Streaming HTML Before It Was Cool. Learn It in 135 Browser Lessons

What this about?

A free interactive tutorial for Marko 6. Real dev server in a WebContainer, covering 0 kB pages, resumability, out-of-order streaming, SSE, and app federated microfrontends.

When React 18 shipped Suspense-aware incremental streaming SSR in 2022, it felt like a breakthrough.Marko has been streaming HTML since 2014 šŸ˜. It powered eBay's product pages back when server rendering was considered a legacy technique. It ships zero JavaScript for static pages. Not a small runtime. Zero. It had a compiler before frontend frameworks had a compiler!

The framework was never the problem. The problem was that trying it meant a lot of setup time.

So I built a tutorial that removes the setup. 135 lessons across 12 parts, running in your browser:

markojs-tutorial.netlify.app

No install, no signup. A real @marko/run app boots inside a StackBlitz WebContainer in your tab. Lesson text on the left, a live editor with a terminal and preview on the right. Edits hot-reload. Every exercise has a Solve button, and every lesson can be downloaded as a runnable project.

Tutorial Preview

It's built with TutorialKit, plus a custom CodeMirror package that runs Marko's actual VS Code TextMate grammar through shiki's WASM engine, so highlighting in the lesson editor matches your IDE exactly.


Here are the patterns the tutorial teaches, and why they're worth your time.

1. Your page is HTML, your interactivity is surgical

A .marko file is HTML with a few additions:

<let/count=0/>

<button onClick() { count++ }>
  Clicked ${count} times
</button>
Enter fullscreen mode Exit fullscreen mode

That's a complete reactive component. No imports, no useState, no export. <let> declares state, and assigning to it is what triggers the update.

The interesting part is what the compiler does next. Marko splits every template into two programs, one for the server and one for the client (Btw, marko had multiple compilation outputs before Svelte), and the client program contains only what the browser actually needs. One lesson has you build a page, run node inspect.mjs, and read the resulting bundle yourself:

  • Static markup becomes plain HTML and ships no JS
  • Server-only computation (a formatPrice helper) stays on the server and never appears in the bundle
  • The one interactive widget is the only thing that ships
  • And an interesting thing to note, Marko does sub-component hydration. This goes beyond islands.

A fully static Marko page ships 0 kB of client JavaScript. Not hydrated cheaply. Not shipped at all.

2. Resumability: the client doesn't re-run your code

Traditional hydration re-executes the client-side component logic for each hydrated boundary so the framework can reconstruct its runtime and wire up interactivity.

Marko 6 resumes instead. The tutorial demonstrates it with a component that has a render-time console.log and a button. The log shows up once, in the server terminal. The browser console stays silent. The button still works. The client never re-ran the render logic; it picked up serialized markers from the HTML and continued from there.

Your render code runs once. You watch it happen live.

3. Streaming: three tags cover everything

The tutorial dedicates whole parts to this (3, 10 and 11), including recreations of Marko's classic streaming demos.

Streaming means sending HTML chunk by chunk as each piece is ready, instead of buffering the whole page. The browser starts parsing, and starts fetching your CSS, fonts and JS, while your slow database query is still running.

<await> for in-order streaming

<h1>Dashboard</h1>

<await|signups|=loadSignups()>   <!-- 1s -->
  ${signups.total} new signups
</await>

<await|traffic|=loadTraffic()>   <!-- 3s -->
  ${traffic.views} views
</await>
Enter fullscreen mode Exit fullscreen mode

The stream holds at each <await> and releases in document order. Simple, no client JS, but the slow section blocks everything below it.

<try> with <@placeholder> for out-of-order streaming

Wrap the slow section:

<try>
  <await|traffic|=loadTraffic()>
    ${traffic.views} views
  </await>

  <@placeholder>
    Crunching traffic numbers…
  </@placeholder>
</try>
Enter fullscreen mode Exit fullscreen mode

Now the server doesn't hold. It streams the placeholder immediately, keeps going with the rest of the page, and when the promise settles it streams the real content, which replaces the placeholder in place. Content below the fold can arrive before content above it. Out of order, on purpose. Note, that while it streams out-of-order, the order is proper at paint.

<@catch> for errors

Same <try> block, rejection path. In the progressive-rendering lesson, the main section races a 5-second timeout with Promise.race, and <@catch> renders the fallback if it loses.

One more thing the tutorial makes a point of: <await> is the same tag on the client. When a browser interaction swaps the promise you're awaiting, the same <await> re-renders the result client-side. One mental model for both runtimes.

The streaming-search lesson recreates Marko's old v3 demo: a single Express handler, template.render({}).pipe(res), and 30 search results arriving in batches down one held-open HTTP response. Pure HTML, no client JavaScript. You can watch the raw HTTP body arrive in chunks.

4. Rendering a live stream with recursion

<for> can't iterate an async stream, so how do you render "one item per event until the stream ends"? You recurse.

<define/Wait>
  <try>
    <await|messages|=Promise.any([
      once(messageStream, "done"),
      once(messageStream, "data"),
    ])>
      <if=messages && messages.length>
        <Notice message=messages[0].message/>
        <Wait/>                     <!-- render the next one -->
      </if>
    </await>
    <@placeholder>Waiting for the stream…</@placeholder>
  </try>
</>

<Wait/>
Enter fullscreen mode Exit fullscreen mode

<define> creates a reusable snippet named Wait. It awaits the next stream event, renders one <Notice>, then invokes itself, so each notice nests inside the last as its own branch of the render tree. When the stream signals done, the <if> fails and the recursion stops.

The payoff: every notice hydrates independently and stays interactive (each has its own dismiss button), even though they arrived one at a time over a live stream. I haven't seen this pattern documented anywhere else.

5. SSE and EventSource

Part 9 covers the browser's native EventSource API, the simplest real-time primitive on the web:

const source = new EventSource("/api/ticker");
source.onmessage = (ev) => { /* a new event arrived */ };
Enter fullscreen mode Exit fullscreen mode

One long-lived HTTP response. The server writes data: frames whenever it wants, the browser fires events. No WebSocket handshake, no library, and reconnection is built in.

Part 10 then combines SSE with the next pattern.

6. App federation: micro-frontends without the iframe tax and the hassle of module federation

Module federation shares code between apps. App federation shares rendered apps. Part 10 teaches it with @micro-frame/marko, running a host app and a remote app at the same time in the same browser tab. Two dev servers, one WebContainer.

The host embeds the remote with one tag:

<micro-frame src="http://localhost:3001/fragment" client-reorder timeout=0>
  <@loading>Loading the remote notice…</@loading>
  <@catch|err|>The remote is unavailable: ${err.message}</@catch>
</micro-frame>
Enter fullscreen mode Exit fullscreen mode

<micro-frame> fetches the remote's fully rendered HTML and transcludes it in place. You get iframe-style ownership isolation with none of the iframe box. The remote team deploys independently, and the host only knows a URL. Loading and error states are just attribute tags.

The SSE chapter pushes this further with a faucet-and-slot pair:

<!-- the faucet: opens ONE SSE stream, renders nothing -->
<micro-frame-sse timeout=0 name="notices"
  src=`http://localhost:3001/sse-notices?slotName=main`
  read(ev) { return [ev.lastEventId, JSON.parse(ev.data), false]; }
/>

<!-- the slot: streamed chunks land here -->
<micro-frame-slot from="notices" slot="main" timeout=0 client-reorder>
  <@loading>Waiting for notices…</@loading>
</micro-frame-slot>
Enter fullscreen mode Exit fullscreen mode

The remote pushes rendered HTML fragments as SSE frames, each tagged with a slot id, and the host drops each chunk into the matching slot. The multi-slot lesson streams different fragments to different regions of the host page over a single connection. Every arriving fragment hydrates and stays interactive.

A live-updating, independently deployed micro-frontend, with loading and error states, in roughly ten lines of template.

7. The Hacker News app

Part 12 tours the official Marko Hacker News reader, adapted to Marko 6, running live in your browser:

  • File-based routing (+page, story.$id+page, +layout)
  • server import keeping the API client server-side
  • Story lists streaming in through <await>
  • A self-recursive comment.marko rendering nested threads to any depth
  • Collapse toggles with <let>

And the bundling payoff, measured: the list and user pages ship 0 kB of client JS. Only the story page ships anything, and that's just the bytes for its collapse toggle.

Why the format matters

Everything actually runs in your browser, in an interactive manner. The dev server, the terminal, hot reload, even two federated apps at once, all inside a WebContainer in your tab.

Every exercise ships its starting files and its solution. Stuck? Solve shows the finished state. Want to keep going? Download the lesson as a complete runnable project.

And the scope goes well past the basics: fundamentals, reactivity, async and error boundaries, components in depth, @marko/run full-stack routing, streaming, progressive rendering, SSE, micro-frontends, GraphQL, SPA routing, CSP nonces, deployment adapters for Node, static and Netlify Edge, and using Marko without its meta-framework at all.

Try it

  1. Open markojs-tutorial.netlify.app
  2. Jump to Part 11 (Streaming) for the fireworks, or Part 1 if you're new
  3. Break something, hit Solve, download the lesson

The repo is open source at github.com/defunkt-dev/markojs-tutorial. Stars ā­ļø and issues welcome.

The ecosystem spent a decade arriving at streaming HTML, islands, and shipping less JavaScript. Marko had the answers the whole time. Now there's a place to learn them šŸš€, in your browser, for free & we are taking it to the world for everyone to see.

Top comments (0)