Saltar al contenido

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.

5 min de lectura
Diagrama comparativo que muestra distintas estrategias de renderizado, desde el SSR completo hasta la hydration parcial, con sus características de rendimiento y compromisos

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.

tstypescript
// ❌ 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
tstypescript
// ✅ 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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX