<!-- Canonical: https://lucioduran.com/es/blog/react-server-components-streaming-architecture
Language: es
Content-SHA256: 87fa5088047e4b2f1285bee836a546709f0eebcf24c5c88cf15577ed85f3b511
-->

Frontend

# React Server Components: Arquitectura de Streaming, Costos de Rendimiento y Patrones de Migración

2026-01-018 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/react-server-components-streaming-architecture>)

## Ciclo de Vida de una Solicitud RSC

Esto es lo que pasa de verdad cuando un browser pide una página con RSC:

1. El servidor recibe el request y arranca a renderizar el árbol de componentes
2. Los Server Components se ejecutan en el servidor, pueden hacer ` await ` de queries a la base, leer archivos, llamar APIs directamente
3. Cuando el servidor llega a un boundary ` 'use client' `, deja de renderizar ese subárbol y emite una **referencia**, un puntero que dice "renderizá este client component aquí con estas props serializadas"
4. El servidor hace stream del output en un formato especial llamado el **RSC payload**, no HTML, sino una descripción serializable del árbol de componentes
5. El runtime de React del lado del cliente recibe este payload, hidrata los client components, y engancha todo

La idea clave: el RSC payload se **streamea**. El servidor no espera a que todos los datos resuelvan antes de mandar el primer byte. Los Suspense boundaries definen los chunks del streaming.

## El Costo de 'use client'

Acá es donde la gente se pierde: ` 'use client' ` no solo significa "este componente corre en el cliente." Significa **este componente Y todo componente que importa** se vuelve parte del bundle del cliente. Es una declaración de boundary, no un toggle por componente.

```jsx
// Este solo 'use client' tira todo al bundle del cliente
'use client';

import { HeavyChartLibrary } from 'heavy-charts'; // 200KB
import { DateFormatter } from './utils'; // 2KB
import { UserAvatar } from './UserAvatar'; // 5KB + procesamiento de imágenes

export function Dashboard({ data }) {
 const [filter, setFilter] = useState('all');
 // ...
}
```

La solución es decomposición quirúrgica:

```jsx
// ServerDashboard.jsx (server component - no necesita directiva)
import { HeavyChartLibrary } from 'heavy-charts'; // ¡se queda en el servidor!
import { DateFormatter } from './utils'; // ¡se queda en el servidor!
import { DashboardFilter } from './DashboardFilter'; // boundary al cliente

export async function ServerDashboard() {
 const data = await db.metrics.getAll(); // acceso directo a DB

 return (
 <div>
 <h1>Dashboard</h1>
 <DashboardFilter /> {/* client component chiquito */}
 {/* El chart pesado se renderiza en el servidor, se manda como HTML */}
 <HeavyChartLibrary data={data} interactive={false} />
 <p>Última actualización: {DateFormatter.relative(data.updatedAt)}</p>
 </div>
 );
}
```

```jsx
// DashboardFilter.jsx
'use client';

import { useState } from 'react';

export function DashboardFilter() {
 const [filter, setFilter] = useState('all');
 return (
 <select value={filter} onChange={(e) => setFilter(e.target.value)}>
 <option value="all">Todos</option>
 <option value="active">Activos</option>
 <option value="archived">Archivados</option>
 </select>
 );
}
```

Esa ` HeavyChartLibrary ` nunca llega al cliente. El chart se renderiza en el servidor y el browser recibe HTML pre-renderizado. El único JavaScript que llega al cliente es el dropdown del filtro.

En una migración de producción real, este patrón recortó el JS client-side del dashboard principal de 340KB a 89KB (gzipped). El First Contentful Paint mejoró 1.2 segundos en una conexión 4G mediana.

## Streaming SSR con Suspense: El Setup Práctico

```jsx
// app/page.jsx (server component por defecto en Next.js App Router)
import { Suspense } from 'react';

export default async function ProductPage({ params }) {
 // Esto resuelve rápido, es un lookup simple de DB
 const product = await getProduct(params.id);

 return (
 <main>
 <h1>{product.name}</h1>
 <p>{product.description}</p>
 <price>{product.price}</price>

 {/* Los reviews pueden ser lentos, no bloqueemos la página */}
 <Suspense fallback={<ReviewsSkeleton />}>
 <ProductReviews productId={params.id} />
 </Suspense>

 {/* Las recomendaciones son todavía más lentas, inferencia ML */}
 <Suspense fallback={<RecommendationsSkeleton />}>
 <RecommendationsPanel productId={params.id} />
 </Suspense>
 </main>
 );
}

async function ProductReviews({ productId }) {
 const reviews = await getReviews(productId); // puede tomar 800ms

 return (
 <section>
 <h2>Reviews ({reviews.length})</h2>
 {reviews.map(r => (
 <ReviewCard key={r.id} review={r} />
 ))}
 </section>
 );
}
```

Lo que ve el usuario:

1. **Instantáneo**: Nombre del producto, descripción, precio + placeholders skeleton
2. **~800ms**: Los reviews aparecen, reemplazando el skeleton
3. **~1500ms**: El panel de recomendaciones se llena

El browser arranca a renderizar ni bien llega el primer chunk. Nada de pantalla blanca esperando al API call más lento.

## Server Actions: RPC Integrado para React

Los Server Actions son funciones que corren en el servidor pero se pueden llamar desde client components. Compilan a POST requests con referencias encriptadas.

```jsx
// actions.js
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createComment(formData) {
 const content = formData.get('content');
 const postId = formData.get('postId');

 // Validar en el servidor, nunca confíes en el cliente
 if (!content || content.length > 5000) {
 return { error: 'El comentario debe tener entre 1 y 5000 caracteres' };
 }

 const user = await getCurrentUser();
 if (!user) {
 redirect('/login');
 }

 await db.comments.create({
 content,
 postId,
 authorId: user.id,
 createdAt: new Date(),
 });

 revalidatePath(`/posts/${postId}`);
 return { success: true };
}
```

```jsx
// CommentForm.jsx
'use client';

import { useActionState } from 'react';
import { createComment } from './actions';

export function CommentForm({ postId }) {
 const [state, formAction, isPending] = useActionState(createComment, null);

 return (
 <form action={formAction}>
 <input type="hidden" name="postId" value={postId} />
 <textarea
 name="content"
 placeholder="Escribir un comentario..."
 disabled={isPending}
 />
 <button type="submit" disabled={isPending}>
 {isPending ? 'Publicando...' : 'Publicar'}
 </button>
 {state?.error && <p className="error">{state.error}</p>}
 </form>
 );
}
```

Lo hermoso: este form funciona sin JavaScript. El ` <form action={formAction}> ` hace progressive enhancement, sin JS, submite como un POST normal. Con JS, React lo intercepta, manda el request en background, y actualiza la UI optimistamente.

Tres forms fueron enviados a producción con este patrón y la historia de progressive enhancement es verdad. En conexiones lentas donde el JS todavía no cargó, los usuarios pueden submitear igual. No es un beneficio teórico, los session replays de usuarios del interior con conexiones 2G lo confirman.

## Patrones de Data Fetching en el Servidor

En el modelo RSC, ` useEffect ` para data fetching y ` react-query ` para cargas iniciales ya no son necesarios. Los datos se obtienen a nivel de componente:

```jsx
async function UserProfile({ userId }) {
 const [user, posts, stats] = await Promise.all([
 db.users.findById(userId),
 db.posts.findByAuthor(userId, { limit: 10 }),
 analytics.getUserStats(userId),
 ]);

 return (
 <div>
 <h1>{user.name}</h1>
 <StatsBanner stats={stats} />
 <PostList posts={posts} />
 <Suspense fallback={<ActivitySkeleton />}>
 <ActivityFeed userId={userId} />
 </Suspense>
 </div>
 );
}
```

Sin loading states para el contenido principal. Sin waterfall requests. Sin el boilerplate de ` useEffect ` + ` useState ` + ` isLoading `. El componente es una función async que fetchea sus datos y renderiza.

El ` Promise.all ` es importante, sin él, las tres queries corren secuencialmente. Este error aparece constantemente en code reviews.

## Estrategia de Migración: El Enfoque Strangler Fig

No se va a reescribir tu app. Vas a mover gradualmente componentes al servidor:

**Paso 1**: Identificar tus dependencias client-side más pesadas. Ejecutar ` npx @next/bundle-analyzer ` y ordená por tamaño.

**Paso 2**: Para cada componente pesado, preguntarse: "¿Esto necesita interactividad?" Si está renderizando contenido estático derivado de datos, es candidato a server component.

**Paso 3**: Extraé los bits interactivos en client components mínimos.

```jsx
// Antes: el componente entero es client-side
'use client';
import { marked } from 'marked'; // 35KB
import DOMPurify from 'dompurify'; // 20KB

export function Article({ markdown }) {
 const [fontSize, setFontSize] = useState(16);
 const html = DOMPurify.sanitize(marked.parse(markdown));

 return (
 <div style={{ fontSize }}>
 <FontSizeControl value={fontSize} onChange={setFontSize} />
 <div dangerouslySetInnerHTML={{ __html: html }} />
 </div>
 );
}
```

```jsx
// Después: procesamiento pesado en servidor, wrapper interactivo mínimo
// ArticleContent.jsx (server component)
import { marked } from 'marked'; // nunca llega al cliente
import DOMPurify from 'dompurify'; // nunca llega al cliente
import { FontSizeWrapper } from './FontSizeWrapper';

export function ArticleContent({ markdown }) {
 const html = DOMPurify.sanitize(marked.parse(markdown));
 return (
 <FontSizeWrapper>
 <div dangerouslySetInnerHTML={{ __html: html }} />
 </FontSizeWrapper>
 );
}

// FontSizeWrapper.jsx (client component, 0.5KB)
'use client';
import { useState } from 'react';

export function FontSizeWrapper({ children }) {
 const [fontSize, setFontSize] = useState(16);
 return (
 <div style={{ fontSize }}>
 <button onClick={() => setFontSize(f => f - 2)}>A-</button>
 <button onClick={() => setFontSize(f => f + 2)}>A+</button>
 {children}
 </div>
 );
}
```

70KB de librerías eliminadas del bundle del cliente.

## Métricas de Performance Medidas

En una página de producto e-commerce real (Next.js 15, App Router, deployed en [Vercel](<https://vercel.com/>)):

| Métrica | Pages Router (CSR) | App Router (RSC + Streaming) |
| --- | --- | --- |
| FCP | 2.1s | 0.8s |
| LCP | 3.4s | 1.2s |
| TTI | 4.2s | 1.8s |
| Client JS (gzip) | 287KB | 112KB |
| TTFB | 180ms | 210ms |

El TTFB es ligeramente *peor* con RSC porque el servidor hace más trabajo antes de mandar el primer byte. Pero todo lo demás es dramáticamente mejor porque el cliente recibe HTML pre-renderizado y menos JavaScript.

## Los Filos Cortantes

**Serialization boundary**: Las props pasadas de server a client components tienen que ser serializables. Nada de funciones, ni clases, ni objetos ` Date ` (utilizar ISO strings), ni ` Map `/` Set `.

**Librerías de terceros**: La mayoría de las librerías React asumen un entorno client. Si se importa una librería que accede a ` window ` o ` document ` en un server component, se va a tener un error de build.

**Debugging**: El formato del RSC payload no es legible por humanos. Cuando algo sale mal en el handoff server-to-client, los mensajes de error son crípticos.

## Cuándo NO Usar RSC

No todo debería ser un server component. UIs altamente interactivas, builders drag-and-drop, editores colaborativos real-time, herramientas basadas en canvas, siguen siendo mejores como apps client-side. RSC brilla para páginas pesadas en contenido con islas de interactividad, que es francamente la mayor parte de la web.

El modelo mental clave: si se están renderizando datos, usar un server component. Si se está manejando estado, usar un client component. El boundary entre ellos es donde la arquitectura resulta interesante, y acertar ese boundary es lo que separa una app RSC rápida de una lenta que simplemente está haciendo SSR con pasos extra.

## Preguntas frecuentes

### ¿Los React Server Components reemplazan SSR?

No, lo complementan. SSR renderiza el HTML inicial. RSC determina qué componentes corren en el servidor vs. el cliente y habilita streaming. Típicamente se utiliza los dos juntos.

### ¿Puedo usar RSC sin Next.js?

Técnicamente sí, el equipo de React publicó una spec de bundler. Pero en la práctica, Next.js y Waku son las únicas implementaciones production-ready. Hacer tu propia integración RSC es un proyecto de meses.

### ¿Cuánto ahorro real hay en bundle size?

Depende de tu árbol de componentes. En una migración representativa, mover data-fetching y renderizado de markdown a server components cortó el JS del cliente un 38%. Las ganancias vienen de dependencias pesadas que nunca necesitan llegar al browser.

## Tips

1. **Tip:** No le metas ` 'use client' ` a un componente solo porque usa ` useState `, levantá el estado a un wrapper client chiquito y dejar el renderizado pesado en el servidor.
2. **Tip:** Colocar tus ` <Suspense> ` boundaries en los bordes de data-fetching, no alrededor de cada componente. Cada boundary agrega un chunk al stream y demasiados causan layout thrash.
3. **Tip:** Los Server Actions son POST requests por debajo. Los es posible llamar desde ` fetch() ` fuera de React, útil para service workers y background sync.
4. **Tip:** Utilizar ` React.cache() ` para deduplicar fetches de datos dentro de un solo render pass. Tiene scope de request, no es persistente, no lo confundas con ` unstable_cache `.

- react
- rsc
- server-components
- streaming
- ssr
- suspense
- next.js
- frontend-architecture

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