<!-- Canonical: https://lucioduran.com/es/blog/streaming-html-out-of-order-island-architecture
Language: es
Content-SHA256: 9b542c2727d7e9ef4171b3acd3e4d3dbc78d5af4975ef325caf6a629eebc67bc
-->

Frontend

# Streaming HTML: Out-of-Order Streaming, Island Architecture e Hidratación Selectiva

2026-01-039 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/streaming-html-out-of-order-island-architecture>)

## Cómo el SSR Tradicional te Falla

En server-side rendering tradicional, el ciclo de vida es así:

```
El browser pide la página
 → El servidor recibe el request
 → El servidor fetchea TODOS los datos (espera al más lento)
 → El servidor renderiza el HTML completo
 → El servidor manda la respuesta completa
 → El browser empieza a parsear
 → El browser descarga el JS
 → Corre la hidratación
 → La página se vuelve interactiva
```

Cada paso es secuencial. El browser no recibe nada hasta que el servidor tiene todo. Esto es fundamentalmente malo para la experiencia del usuario porque se queda mirando una pantalla en blanco mientras el servidor espera una query lenta a la base o una API de terceros.

## Entra el Streaming HTML

HTTP/1.1 soporta chunked transfer encoding desde 1999. La idea es simple: en vez de buffear toda la respuesta, mandá chunks a medida que estén disponibles.

```javascript
// Node.js streaming response - lo básico
import { Readable } from 'stream';

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

 // Mandá el shell inmediatamente
 res.write(`
 <!DOCTYPE html>
 <html>
 <head><title>Producto</title><link rel="stylesheet" href="/styles.css"></head>
 <body>
 <header><!-- el nav renderiza inmediatamente --></header>
 <main>
 `);

 // Los detalles del producto vienen rápido - mandalos
 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">Cargando reviews...</div>
 <div id="recommendations-placeholder">Cargando recomendaciones...</div>
 `);

 // Disparamos los requests lentos en paralelo
 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>');
});
```

Esto es rudimentario pero ilustrativo. El browser recibe el header y la info del producto en 200ms y puede empezar a renderizar. Las reviews y recomendaciones gotean después. Pero hay un problema: seguimos esperando las reviews antes de mandar las recomendaciones, aunque las recomendaciones podrían terminar primero.

## Out-of-Order Streaming

Acá resulta interesante. El out-of-order streaming desacopla el orden de resolución de datos del orden de aparición en el DOM. El servidor manda elementos placeholder, y después los reemplaza con contenido real a medida que cada fuente de datos se resuelve, en el orden que sea que terminen.

Marko viene haciendo esto desde 2019. El modelo mental es así:

```html
<!-- Lo que el servidor manda PRIMERO (inmediatamente) -->
<div id="product-shell">
 <h1>Cargando...</h1>
</div>
<div id="reviews-slot" hidden>
 <!-- Placeholder, se va a swappear -->
</div>
<div id="recs-slot" hidden>
 <!-- Placeholder, se va a swappear -->
</div>

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

<!-- Lo que el servidor manda cuando los datos del producto se resuelven (~180ms) -->
<script>$RS('product-shell', '<h1>Nike Air Max 90</h1><p>$129.99</p>')</script>

<!-- Lo que el servidor manda cuando las recomendaciones se resuelven (~800ms) -->
<!-- Ojo: esto llega ANTES que las reviews aunque está después en el DOM -->
<script>$RS('recs-slot', '<div class="recs">...</div>')</script>

<!-- Lo que el servidor manda cuando las reviews finalmente se resuelven (~1200ms) -->
<script>$RS('reviews-slot', '<div class="reviews">...</div>')</script>
```

Lo hermoso es que el browser procesa cada chunk a medida que llega. El usuario ve el producto casi al instante, después aparecen las recomendaciones, después las reviews. No se necesita un framework de JavaScript para este render inicial, solo scripts inline y manipulación del DOM.

### La Implementación de Marko

El approach de Marko al out-of-order streaming es el más maduro. Usa un tag ` <await> ` que crea 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>
```

El atributo ` client-reorder ` es la clave. Sin él, Marko streamea en orden del documento (esperando a los await blocks anteriores antes de los posteriores). Con él, cada bloque se resuelve independientemente, y Marko inyecta un runtime diminuto (~1KB) para manejar los DOM swaps.

### El Approach de React con Suspense

React 18+ soporta streaming SSR con 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;
 }
 }
 );
});
```

El streaming de React funciona, pero viene con un costo: teners que hidratar el árbol de componentes entero. Aunque solo dos componentes sean interactivos, React descarga y re-ejecuta todo. Acá es donde entra la island architecture.

## Island Architecture: Interactividad Selectiva

El término "island architecture" viene del blog post de Jason Miller de 2020, aunque el concepto existía de antes. La idea: una página es un mar de HTML estático con islas de interactividad. Solo las islas envían JavaScript.

### La Implementación de Astro

[Astro](<https://astro.build/>) es el referente de island architecture:

```astro
---
// product-page.astro
import Layout from '../layouts/Layout.astro';
import ProductDetails from '../components/ProductDetails.astro'; // ¡Estático!
import ReviewList from '../components/ReviewList.astro'; // ¡Estático!
import AddToCart from '../components/AddToCart.tsx'; // ¡Isla!
import ImageCarousel from '../components/ImageCarousel.svelte'; // ¡Isla!

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

<Layout title={product.name}>
 <!-- HTML estático, cero JS -->
 <ProductDetails product={product} />

 <!-- Isla interactiva: carga cuando es visible en el viewport -->
 <ImageCarousel client:visible images={product.images} />

 <!-- Isla interactiva: carga inmediatamente (above the fold, crítico) -->
 <AddToCart client:load productId={product.id} price={product.price} />

 <!-- HTML estático, cero JS -->
 <ReviewList reviews={reviews} />
</Layout>
```

Las directivas ` client:* ` controlan cuándo carga el JavaScript de cada isla:

```typescript
// Estrategias de carga de islas en Astro
client:load // Carga e hidrata inmediatamente al cargar la página
client:idle // Carga después de la carga inicial (requestIdleCallback)
client:visible // Carga cuando el componente entra al viewport (IntersectionObserver)
client:media // Carga cuando se cumple un CSS media query
client:only // Saltea SSR, solo render en cliente
```

### Qwik: Más Allá de las Islas

[Qwik](<https://qwik.dev/>) toma un enfoque fundamentalmente diferente. En vez de hidratación (re-ejecutar código de componentes en el cliente), Qwik usa resumabilidad. El servidor serializa el estado de los componentes y los bindings de eventos en el HTML, y el cliente literalmente retoma de donde dejó el servidor.

```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);

 // Este handler se carga lazy SOLO cuando el usuario clickea
 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}>Agregar al Carrito</button>

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

El marcador ` $() ` le dice al optimizador de Qwik dónde crear boundaries de lazy-loading. Cada ` $ ` se convierte en un chunk separado que solo se descarga cuando se dispara. La carga inicial de la página envía prácticamente cero JavaScript. El click handler de "Agregar al Carrito" se fetchea de la red solo cuando el usuario clickea el botón.

Observar cómo se ve el HTML output de Qwik:

```html
<button on:click="product-page_component_addtocart_0lz8qwik.js#s_addToCart"
 q:id="3">
 Agregar al Carrito
</button>
```

Ese atributo ` on:click ` contiene una URL al click handler. Cuando el usuario clickea, el runtime diminuto (~1KB) de Qwik fetchea ese script, deserializa el estado y ejecuta el handler. Sin hidratación. Sin re-renderizar todo el árbol. Solo el handler.

## Comparación de Performance: Números Reales

Benchmarkeé la misma página de producto con tres enfoques en un dataset real de e-commerce. Mismo servidor (AWS t3.medium), mismas latencias de API, mismo contenido:

```
┌────────────────────┬─────────┬───────────┬──────────────┬───────────┐
│ Métrica │ 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 transferido │ 187KB │ 24KB │ 2KB inicial │ 42KB │
│ Total blocking │ 340ms │ 45ms │ 0ms │ 120ms │
└────────────────────┴─────────┴───────────┴──────────────┴───────────┘
```

Marko gana en TTFB porque su out-of-order streaming manda el primer byte casi inmediatamente. Qwik gana en TTI porque efectivamente no hay costo de hidratación. Astro queda en un punto óptimo para páginas heavy en contenido. React SSR sin streaming es el baseline que todos tratan de superar.

## Cuándo Usar Qué

Después de construir sitios en producción con los tres enfoques, aquí va mi evaluación honesta:

**Usar Astro** cuando tu sitio es primariamente contenido con toques de interactividad. Blogs, documentación, sitios de marketing, portfolios. El modelo de islas mapea perfecto a estos case es de uso.

**Usar Qwik** cuando estés construyendo una aplicación completa, dashboards, e-commerce, productos SaaS, donde cada componente es potencialmente interactivo, pero se desea performance nivel MPA. La curva de aprendizaje de la sintaxis ` $ ` es real, y el ecosistema es más chico, pero las características de performance son notables.

**Usar Marko** si se está a escala eBay y se necesita la implementación de streaming más battle-tested. Marko viene streameando HTML en producción hace años en eBay, manejando millones de requests. El ecosistema afuera de eBay es chico, que es su mayor limitación.

**Usar React con streaming** si tu equipo ya sabe React y no es posible justificar una migración de framework. El streaming SSR de React con Suspense es una mejora masiva sobre el enfoque con buffer, aunque no matchee la eficiencia cruda de las alternativas.

La industria está convergiendo en un modelo donde el servidor hace la mayor parte del trabajo de rendering, y el cliente solo maneja interactividad genuina. Ya sea que lo llamen islas, resumabilidad o hidratación selectiva, la dirección es clara. La era de enviar 300KB de JavaScript para renderizar una tarjeta de producto se está terminando, y es un cambio bienvenido.

## Preguntas frecuentes

### ¿Qué es el out-of-order streaming?

Es una técnica donde el servidor envía chunks de HTML a medida que están listos, sin importar su posición en el DOM. Se renderiza un placeholder primero, que después se reemplaza con contenido real via scripts inline cuando los datos se resuelven. Marko fue pionero en este approach.

### ¿En qué se diferencia island architecture de partial hydration?

Island architecture trata los componentes interactivos como islas aisladas en un mar de HTML estático. Partial hydration es el mecanismo que hace funcionar las islas, solo el código de la isla se descarga y ejecuta, no el JavaScript de toda la página.

### ¿Debería usar Marko, Qwik o Astro para un proyecto nuevo?

Astro para sitios heavy en contenido con toques de interactividad. Qwik para aplicaciones completas donde se desea feel de SPA con performance de MPA. Marko si se está en el ecosistema de eBay o se desea la implementación de streaming más madura.

## Tips

1. **Tip:** El out-of-order streaming solo ayuda si tu shell renderiza rápido. Colocar los ` <Suspense> ` boundaries alrededor de data fetches lentos, no alrededor de componentes de layout.
2. **Tip:** Con islas en Astro, utilizar ` client:visible ` para componentes interactivos below-the-fold y ` client:idle ` para UI no crítica como widgets de analytics.
3. **Tip:** La resumabilidad de Qwik elimina la hidratación por completo, pero solo si evitás ` useVisibleTask$ ` que fuerza ejecución eager. Tratalo como escape hatch, no como default.
4. **Tip:** Probar streaming con ` curl --no-buffer ` para ver realmente los chunks llegando. El DevTools del browser comprime el stream y oculta el comportamiento real.

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

[Volver al Blog](<https://lucioduran.com/es/blog>)
