React Server Components: Arquitectura de Streaming, Costos de Rendimiento y Patrones de Migración
Ciclo de Vida de una Solicitud RSC
Esto es lo que pasa de verdad cuando un browser pide una página con RSC:
- El servidor recibe el request y arranca a renderizar el árbol de componentes
- Los Server Components se ejecutan en el servidor — pueden hacer
awaitde queries a la base, leer archivos, llamar APIs directamente - 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" - 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
- 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.
// 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:
// 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>
);
}
// 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
// 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:
- Instantáneo: Nombre del producto, descripción, precio + placeholders skeleton
- ~800ms: Los reviews aparecen, reemplazando el skeleton
- ~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.
// 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 };
}
// 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:
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.
// 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>
);
}
// 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):
| 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.