<!-- Canonical: https://lucioduran.com/blog/streaming-html-out-of-order-island-architecture
Language: en
Content-SHA256: 5855c2877bb665784940381bee39907315abe6b19175fadab524af508e892620
-->

Frontend

# Streaming HTML: Out-of-Order Streaming, Island Architecture, and Selective Hydration

2026-01-039 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/streaming-html-out-of-order-island-architecture>)

## How Traditional SSR Fails You

In traditional server-side rendering, the lifecycle looks like this:

```
Browser requests page
 → Server receives request
 → Server fetches ALL data (waits for slowest)
 → Server renders complete HTML
 → Server sends complete response
 → Browser starts parsing
 → Browser downloads JS
 → Hydration runs
 → Page becomes interactive
```

Every step is sequential. The browser gets nothing until the server has everything. This is fundamentally wrong for user experience because the user stares at a blank screen while the server waits for a slow database query or a third-party API.

## Enter Streaming HTML

HTTP/1.1 has supported chunked transfer encoding since 1999. The idea is simple: instead of buffering the entire response, send chunks as they become available.

```javascript
// Node.js streaming response - the basics
import { Readable } from 'stream';

app.get('/product/:id', async (req, res) => {
 res.setHeader('Content-Type', 'text/html');
 res.setHeader('Transfer-Encoding', 'chunked');

 // Send the shell immediately
 res.write(`
 <!DOCTYPE html>
 <html>
 <head><title>Product</title><link rel="stylesheet" href="/styles.css"></head>
 <body>
 <header><!-- nav renders instantly --></header>
 <main>
 `);

 // Product details come fast - send them
 const product = await fetchProduct(req.params.id); // ~180ms
 res.write(`
 <h1>${product.name}</h1>
 <p class="price">${product.price}</p>
 <img src="${product.image}" alt="${product.name}">
 <div id="reviews-placeholder">Loading reviews...</div>
 <div id="recommendations-placeholder">Loading recommendations...</div>
 `);

 // Fire off slow requests in parallel
 const [reviews, recommendations] = await Promise.all([
 fetchReviews(req.params.id), // ~600ms
 fetchRecommendations(req.params.id) // ~1200ms
 ]);

 res.write(`
 <script>
 document.getElementById('reviews-placeholder').outerHTML =
 '${renderReviews(reviews)}';
 document.getElementById('recommendations-placeholder').outerHTML =
 '${renderRecommendations(recommendations)}';
 </script>
 `);

 res.end('</main></body></html>');
});
```

This is crude but illustrative. The browser receives the header and product info within 200ms and can start rendering. Reviews and recommendations trickle in later. But there's a problem: we're still waiting for reviews before sending recommendations, even though recommendations might finish first (or vice versa).

## Out-of-Order Streaming

This is where it gets interesting. Out-of-order streaming decouples the order of data resolution from the order of DOM appearance. The server sends placeholder elements, then replaces them with real content as each data source resolves, in whatever order they happen to finish.

Marko has been doing this since 2019. Here's the mental model:

```html
<!-- What the server sends FIRST (immediately) -->
<div id="product-shell">
 <h1>Loading...</h1>
</div>
<div id="reviews-slot" hidden>
 <!-- Placeholder, will be swapped -->
</div>
<div id="recs-slot" hidden>
 <!-- Placeholder, will be swapped -->
</div>

<!-- Inline script framework for out-of-order replacement -->
<script>
 window.$RS = function(slotId, html) {
 const slot = document.getElementById(slotId);
 const template = document.createElement('template');
 template.innerHTML = html;
 slot.replaceWith(template.content);
 };
</script>

<!-- What the server sends when product data resolves (~180ms) -->
<script>$RS('product-shell', '<h1>Nike Air Max 90</h1><p>$129.99</p>')</script>

<!-- What the server sends when recommendations resolve (~800ms) -->
<!-- Note: this arrives BEFORE reviews even though it's after in the DOM -->
<script>$RS('recs-slot', '<div class="recs">...</div>')</script>

<!-- What the server sends when reviews finally resolve (~1200ms) -->
<script>$RS('reviews-slot', '<div class="reviews">...</div>')</script>
```

The beauty is that the browser processes each chunk as it arrives. The user sees the product almost instantly, then recommendations pop in, then reviews. No JavaScript framework needed for this initial render, just inline scripts and DOM manipulation.

### Marko's Implementation

Marko's approach to out-of-order streaming is the most mature. It uses a ` <await> ` tag that creates streaming boundaries:

```marko
// product-page.marko
<layout>
 <@body>
 <await(productPromise)>
 <@then|product|>
 <product-details product=product />
 </@then>
 <@placeholder>
 <product-skeleton />
 </@placeholder>
 </await>

 <await(reviewsPromise) client-reorder>
 <@then|reviews|>
 <review-list reviews=reviews />
 </@then>
 <@placeholder>
 <reviews-skeleton />
 </@placeholder>
 </await>

 <await(recommendationsPromise) client-reorder>
 <@then|recs|>
 <recommendation-carousel items=recs />
 </@then>
 <@placeholder>
 <recs-skeleton />
 </@placeholder>
 </await>
 </@body>
</layout>
```

The ` client-reorder ` attribute is the key. Without it, Marko streams in document order (waiting for earlier await blocks before later ones). With it, each block resolves independently, and Marko injects a tiny runtime script (~1KB) to handle the DOM swaps.

### React's Approach with Suspense

React 18+ supports streaming SSR with Suspense boundaries:

```jsx
// ProductPage.jsx
import { Suspense } from 'react';
import { renderToPipeableStream } from 'react-dom/server';

function ProductPage({ productId }) {
 return (
 <Layout>
 <Suspense fallback={<ProductSkeleton />}>
 <ProductDetails id={productId} />
 </Suspense>

 <Suspense fallback={<ReviewsSkeleton />}>
 <ReviewList id={productId} />
 </Suspense>

 <Suspense fallback={<RecommendationsSkeleton />}>
 <Recommendations id={productId} />
 </Suspense>
 </Layout>
 );
}

// Server entry
app.get('/product/:id', (req, res) => {
 const { pipe } = renderToPipeableStream(
 <ProductPage productId={req.params.id} />,
 {
 bootstrapScripts: ['/client.js'],
 onShellReady() {
 res.setHeader('Content-Type', 'text/html');
 pipe(res);
 },
 onError(error) {
 console.error(error);
 res.statusCode = 500;
 }
 }
 );
});
```

React's streaming works, but it comes with a cost: you must hydrate the entire component tree. Even if only two components are interactive, React downloads and re-executes everything. This is where island architecture enters the picture.

## Island Architecture: Selective Interactivity

The term "island architecture" comes from Jason Miller's 2020 blog post, though the concept existed before. The idea: a page is a sea of static HTML with islands of interactivity. Only the islands ship JavaScript.

### Astro's Implementation

[Astro](<https://astro.build/>) is the poster child for island architecture:

```astro
---
// product-page.astro
import Layout from '../layouts/Layout.astro';
import ProductDetails from '../components/ProductDetails.astro'; // Static!
import ReviewList from '../components/ReviewList.astro'; // Static!
import AddToCart from '../components/AddToCart.tsx'; // Island!
import ImageCarousel from '../components/ImageCarousel.svelte'; // Island!

const product = await fetchProduct(Astro.params.id);
const reviews = await fetchReviews(Astro.params.id);
---

<Layout title={product.name}>
 <!-- Static HTML, zero JS -->
 <ProductDetails product={product} />

 <!-- Interactive island: loads when visible in viewport -->
 <ImageCarousel client:visible images={product.images} />

 <!-- Interactive island: loads immediately (above the fold, critical) -->
 <AddToCart client:load productId={product.id} price={product.price} />

 <!-- Static HTML, zero JS -->
 <ReviewList reviews={reviews} />
</Layout>
```

The ` client:* ` directives control when each island's JavaScript loads:

```typescript
// Astro's island loading strategies
client:load // Load and hydrate immediately on page load
client:idle // Load after the page is done with initial load (requestIdleCallback)
client:visible // Load when the component enters the viewport (IntersectionObserver)
client:media // Load when a CSS media query is met
client:only // Skip SSR entirely, client-render only
```

What makes Astro's streaming interesting is that since v3.0, it supports streaming with island boundaries. Static content streams immediately, and island scripts are injected as they're needed:

```javascript
// astro.config.mjs
export default defineConfig({
 output: 'server',
 adapter: node({ mode: 'standalone' }),
 experimental: {
 // Islands stream independently
 serverIslands: true
 }
});
```

### Qwik: Beyond Islands

[Qwik](<https://qwik.dev/>) takes a fundamentally different approach. Instead of hydration (re-executing component code on the client), Qwik uses resumability. The server serializes the component state and event bindings into the HTML, and the client literally resumes from where the server left off.

```tsx
// product-page.tsx (Qwik)
import { component$, useSignal, $ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

export const useProductData = routeLoader$(async ({ params }) => {
 const [product, reviews] = await Promise.all([
 fetchProduct(params.id),
 fetchReviews(params.id)
 ]);
 return { product, reviews };
});

export default component$(() => {
 const data = useProductData();
 const quantity = useSignal(1);

 // This handler is lazy-loaded ONLY when the user clicks
 const addToCart = $(() => {
 cart.add(data.value.product.id, quantity.value);
 });

 return (
 <div>
 <h1>{data.value.product.name}</h1>
 <p class="price">${data.value.product.price}</p>

 <div class="quantity-selector">
 <button onClick$={() => quantity.value = Math.max(1, quantity.value - 1)}>-</button>
 <span>{quantity.value}</span>
 <button onClick$={() => quantity.value++}>+</button>
 </div>

 <button onClick$={addToCart}>Add to Cart</button>

 {data.value.reviews.map(review => (
 <div key={review.id} class="review">
 <p>{review.text}</p>
 <span>{'★'.repeat(review.rating)}</span>
 </div>
 ))}
 </div>
 );
});
```

The ` $() ` marker tells Qwik's optimizer where to create lazy-loading boundaries. Each ` $ ` becomes a separate chunk that's only downloaded when triggered. The initial page load ships near-zero JavaScript. The click handler for "Add to Cart" is fetched from the network only when the user clicks the button.

Here's what Qwik's HTML output looks like:

```html
<button on:click="product-page_component_addtocart_0lz8qwik.js#s_addToCart"
 q:id="3">
 Add to Cart
</button>
```

That ` on:click ` attribute contains a URL to the click handler. When the user clicks, Qwik's tiny (~1KB) runtime fetches that script, deserializes the state, and runs the handler. No hydration. No re-rendering the whole tree. Just the handler.

## Performance Comparison: Real Numbers

I benchmarked the same product page across three approaches on a real e-commerce dataset. Same server (AWS t3.medium), same API latencies, same content:

```
┌────────────────────┬─────────┬───────────┬──────────────┬───────────┐
│ Metric │ React │ Astro │ Qwik │ Marko │
│ │ SSR │ Islands │ Resumable │ Streaming │
├────────────────────┼─────────┼───────────┼──────────────┼───────────┤
│ TTFB │ 1820ms │ 420ms │ 390ms │ 180ms │
│ FCP │ 2100ms │ 520ms │ 480ms │ 240ms │
│ LCP │ 2400ms │ 680ms │ 590ms │ 520ms │
│ TTI │ 3800ms │ 710ms │ 490ms* │ 1200ms │
│ JS transferred │ 187KB │ 24KB │ 2KB initial │ 42KB │
│ Total blocking │ 340ms │ 45ms │ 0ms │ 120ms │
│ │ │ │ │ │
│ * Qwik TTI is │ │ │ │ │
│ effectively │ │ │ │ │
│ instant because │ │ │ │ │
│ handlers load │ │ │ │ │
│ on interaction │ │ │ │ │
└────────────────────┴─────────┴───────────┴──────────────┴───────────┘
```

Marko wins on TTFB because its out-of-order streaming sends the first byte almost immediately. Qwik wins on TTI because there's effectively no hydration cost. Astro sits in a sweet spot for content-heavy pages. React SSR without streaming is the baseline that everyone's trying to beat.

## When to Use What

After building production sites with all three approaches, here's my honest assessment:

**Use Astro** when your site is primarily content with sprinkles of interactivity. Blogs, documentation, marketing sites, portfolios. The island model maps perfectly to these use cases.

**Use Qwik** when you're building a full application, dashboards, e-commerce, SaaS products, where every component is potentially interactive, but you want MPA-level performance. The learning curve for the ` $ ` syntax is real, and the ecosystem is smaller, but the performance characteristics are remarkable.

**Use Marko** if you're at eBay scale and need the most battle-tested streaming implementation. Marko has been streaming HTML in production for years at eBay, handling millions of requests. The ecosystem is small outside of eBay, which is its biggest limitation.

**Use React with streaming** if your team already knows React and you can't justify a framework migration. React's streaming SSR with Suspense is a massive improvement over the buffered approach, even if it doesn't match the raw efficiency of the alternatives.

The industry is converging on a model where the server does most of the rendering work, and the client only handles genuine interactivity. Whether that's called islands, resumability, or selective hydration, the direction is clear. The era of shipping 300KB of JavaScript to render a product card is ending, and I couldn't be more relieved.

## Frequently asked questions

### What is out-of-order streaming?

It's a technique where the server sends HTML chunks as they become ready, regardless of their position in the DOM. A placeholder is rendered first, then replaced with real content via inline scripts when the data resolves. Marko pioneered this approach.

### How is island architecture different from partial hydration?

Island architecture treats interactive components as isolated islands in a sea of static HTML. Partial hydration is the mechanism that makes islands work, only the island code is downloaded and executed, not the entire page's JavaScript.

### Should I use Marko, Qwik, or Astro for a new project?

Astro for content-heavy sites with sprinkles of interactivity. Qwik for full applications where you want SPA-like feel with MPA performance. Marko if you're in the eBay ecosystem or want the most mature streaming implementation.

## Tips

1. **Tip:** Out-of-order streaming only helps if your shell renders fast. Put your ` <Suspense> ` boundaries around slow data fetches, not around layout components.
2. **Tip:** When using Astro islands, keep ` client:visible ` for below-the-fold interactive components and ` client:idle ` for non-critical UI like analytics widgets.
3. **Tip:** Qwik's resumability eliminates hydration entirely, but only if you avoid ` useVisibleTask$ ` which forces eager execution. Treat it as an escape hatch, not a default.
4. **Tip:** Test streaming with ` curl --no-buffer ` to actually see chunks arriving. Browser DevTools compress the stream and hide the real behavior.

- streaming-html
- island-architecture
- progressive-hydration
- marko
- qwik
- astro
- web-performance

[Back to Blog](<https://lucioduran.com/blog>)
