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.
// 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:
<!-- 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:
// 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:
// 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 is the poster child for island architecture:
---
// 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:
// 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:
// astro.config.mjs
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
experimental: {
// Islands stream independently
serverIslands: true
}
});
Qwik: Beyond Islands
Qwik 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.
// 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:
<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.