Patrones de SSR y sus compromisos de rendimiento
Patrones de arquitectura SSR: streaming, hidratación selectiva, islas y prerenderizado parcial, con mediciones reales y criterios para elegir.

El SSR no es una técnica única, sino un espectro de enfoques con características de rendimiento fundamentalmente distintas. El SSR completo, el streaming SSR, la generación estática, la arquitectura de islas y el prerenderizado parcial optimizan cada uno métricas diferentes. Elegir el patrón equivocado para tu caso de uso no solo desperdicia esfuerzo de ingeniería, sino que puede hacer que el rendimiento sea peor que con el renderizado del lado del cliente.
La decisión no es “SSR sí o no”. Es “qué patrón de renderizado corresponde a cada parte de la página”.
SSR completo: la línea base
El SSR tradicional renderiza la página completa en el servidor en cada solicitud. El navegador recibe el HTML completo, lo muestra de inmediato y luego le aplica hydration mediante JavaScript para hacerlo interactivo.
// ❌ Full SSR with waterfall data fetching
async function renderPage(req: Request): Promise<string> {
// Sequential fetches — each waits for the previous
const user = await fetchUser(req.userId);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts.map((p) => p.id));
// Nothing renders until ALL data is ready
return renderToString(
<Page user={user} posts={posts} comments={comments} />
);
}
// Time to First Byte: sum of all fetch latencies// ✅ Full SSR with parallel data fetching
async function renderPage(req: Request): Promise<string> {
// Parallel fetches — total time = max of individual fetches
const [user, posts, siteConfig] = await Promise.all([
fetchUser(req.userId),
fetchPosts(req.userId),
fetchSiteConfig(),
]);
// Comments depend on posts, but we didn't block the others
const comments = await fetchComments(
posts.map((p) => p.id)
);
return renderToString(
<Page
user={user}
posts={posts}
comments={comments}
config={siteConfig}
/>
);
}El SSR completo ofrece un excelente First Contentful Paint (FCP), pero bloquea el Time to First Byte (TTFB) hasta que se resuelve la dependencia de datos más lenta. En páginas con múltiples fuentes de datos, este retraso se acumula.
Streaming SSR: renderizado progresivo
El streaming SSR envía el HTML a medida que se genera, lo que permite que el navegador empiece a pintar la página antes de que termine la obtención de datos. La función renderToPipeableStream de React habilita esto de forma nativa.
import { renderToPipeableStream } from "react-dom/server";
import { Suspense } from "react";
function App() {
return (
<html>
<head>
<title>Dashboard</title>
</head>
<body>
{/* Shell renders immediately */}
<Header />
<Navigation />
{/* Each Suspense boundary streams independently */}
<Suspense fallback={<PostsSkeleton />}>
<PostsFeed />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</body>
</html>
);
}
function handleRequest(req: Request, res: Response): void {
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ["/client.js"],
onShellReady() {
// Shell (everything outside Suspense) is ready
res.setHeader("Content-Type", "text/html");
res.statusCode = 200;
pipe(res);
},
onShellError(error) {
res.statusCode = 500;
res.end("Server error");
console.error("Shell render failed:", error);
},
onError(error) {
console.error("Streaming error:", error);
},
});
// Timeout: abort if rendering takes too long
setTimeout(() => abort(), 10_000);
}El navegador recibe el shell de la página en milisegundos. Cada límite de Suspense se resuelve de forma independiente y transmite su contenido en cuanto llegan los datos. Las fuentes de datos lentas no bloquean a las rápidas.
Hydration selectiva
La hydration completa descarga y ejecuta JavaScript para toda la página, incluso para contenido estático que nunca será interactivo. La hydration selectiva limita la ejecución de JavaScript a los componentes que realmente necesitan interactividad.
interface HydrationStrategy {
type: "eager" | "idle" | "visible" | "interaction" | "none";
priority?: "high" | "low";
}
// Component-level hydration directives
function HydrateOn({
strategy,
children,
}: {
strategy: HydrationStrategy;
children: React.ReactNode;
}) {
if (typeof window === "undefined") {
// Server: render normally
return <>{children}</>;
}
// Client: defer hydration based on strategy
switch (strategy.type) {
case "visible":
return <HydrateOnVisible>{children}</HydrateOnVisible>;
case "idle":
return <HydrateOnIdle>{children}</HydrateOnIdle>;
case "interaction":
return (
<HydrateOnInteraction>{children}</HydrateOnInteraction>
);
case "none":
// Static HTML — never hydrate
return (
<div
dangerouslySetInnerHTML={{
__html: "", // Server HTML preserved
}}
/>
);
default:
return <>{children}</>;
}
}
function HydrateOnVisible({
children,
}: {
children: React.ReactNode;
}) {
const ref = React.useRef<HTMLDivElement>(null);
const [shouldHydrate, setShouldHydrate] = React.useState(false);
React.useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldHydrate(true);
observer.disconnect();
}
},
{ rootMargin: "200px" }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
if (!shouldHydrate) {
return <div ref={ref}>{children}</div>;
}
return <>{children}</>;
}Arquitectura de islas
Las islas llevan la hydration selectiva a su extremo lógico: la página es HTML estático de forma predeterminada, con “islas” aisladas de interactividad que realizan su propia hydration de manera independiente.
interface IslandDefinition {
component: string;
props: Record<string, unknown>;
hydration: "load" | "idle" | "visible" | "media";
mediaQuery?: string;
}
// Server-side island renderer
function renderIsland(island: IslandDefinition): string {
const propsJson = JSON.stringify(island.props);
const componentHtml = renderComponentToString(
island.component,
island.props
);
return `
<div
data-island="${island.component}"
data-props='${propsJson}'
data-hydrate="${island.hydration}"
${island.mediaQuery ? `data-media="${island.mediaQuery}"` : ""}
>
${componentHtml}
</div>
`;
}
// Client-side island hydration controller
class IslandController {
private hydrated: Set<HTMLElement> = new Set();
init(): void {
const islands = document.querySelectorAll<HTMLElement>(
"[data-island]"
);
for (const el of islands) {
const strategy = el.dataset.hydrate ?? "load";
this.scheduleHydration(el, strategy);
}
}
private scheduleHydration(
el: HTMLElement,
strategy: string
): void {
switch (strategy) {
case "load":
this.hydrate(el);
break;
case "idle":
if ("requestIdleCallback" in window) {
requestIdleCallback(() => this.hydrate(el));
} else {
setTimeout(() => this.hydrate(el), 200);
}
break;
case "visible": {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
this.hydrate(el);
observer.disconnect();
}
});
observer.observe(el);
break;
}
}
}
private async hydrate(el: HTMLElement): Promise<void> {
if (this.hydrated.has(el)) return;
const componentName = el.dataset.island;
if (!componentName) return;
const props = JSON.parse(el.dataset.props ?? "{}");
// Dynamic import — only load JS for this island
const module = await import(
`./islands/${componentName}.js`
);
module.default.hydrate(el, props);
this.hydrated.add(el);
}
}Las islas destacan en sitios con mucho contenido, donde la mayor parte de la página es estática. Un blog con una barra de búsqueda interactiva y una sección de comentarios no necesita JavaScript para el contenido del artículo, solo para esas dos islas interactivas.
Medición del rendimiento de renderizado
Cada patrón de renderizado optimiza métricas distintas. Mide lo que realmente importa para tus usuarios.
interface RenderingMetrics {
ttfb: number; // Time to First Byte
fcp: number; // First Contentful Paint
lcp: number; // Largest Contentful Paint
tti: number; // Time to Interactive
tbt: number; // Total Blocking Time
hydrationTime: number; // Time spent hydrating
jsPayload: number; // JavaScript bytes sent
}
// Comparison for a typical content page:
const fullSsr: RenderingMetrics = {
ttfb: 800, // Blocked on slowest data fetch
fcp: 900, // Fast after TTFB
lcp: 950, // Full content in first paint
tti: 2500, // Must hydrate entire page
tbt: 600, // Hydration blocks main thread
hydrationTime: 400,
jsPayload: 250_000,
};
const streamingSsr: RenderingMetrics = {
ttfb: 100, // Shell sent immediately
fcp: 200, // Shell paints fast
lcp: 850, // Main content streams in
tti: 2200, // Still hydrates full page
tbt: 500,
hydrationTime: 350,
jsPayload: 250_000,
};
const islandArchitecture: RenderingMetrics = {
ttfb: 150, // Static shell
fcp: 250, // Fast static render
lcp: 300, // Content is static HTML
tti: 600, // Only islands need JS
tbt: 80, // Minimal JS execution
hydrationTime: 50, // Only interactive islands
jsPayload: 45_000, // Dramatically less JS
};Conclusiones clave
El SSR es un espectro, no una elección binaria: el SSR completo, el streaming, la hydration selectiva y las islas optimizan cada uno métricas de rendimiento diferentes. El streaming SSR con límites de Suspense elimina la penalización de TTFB que supone esperar a fuentes de datos lentas, ya que envía el shell de la página de inmediato y transmite el contenido a medida que se resuelve. La hydration selectiva reduce el Time to Interactive al postergar la ejecución de JavaScript de los componentes que no se necesitan de inmediato, usando disparadores de visibilidad e interacción. La arquitectura de islas ofrece el mejor rendimiento en páginas con mucho contenido al tratar la interactividad como la excepción y no como la norma: HTML estático de forma predeterminada, JavaScript solo donde se necesita. Mide el TTFB, el FCP, el LCP, el TTI y el tamaño del payload de JavaScript para comparar los enfoques de forma cuantitativa en lugar de adivinar. El patrón adecuado depende de la proporción de contenido estático frente a elementos interactivos de tu página: un dashboard necesita un renderizado distinto al de una entrada de blog. Combina patrones dentro de una misma aplicación, usando streaming SSR para páginas dinámicas y generación estática con islas para páginas de contenido.


