DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Streaming SSR con Next.js 15 y Lambda Response Streaming arquitectura avanzada

Lambda Response Streaming es una de las features más subestimadas de AWS Lambda. Habilita algo que antes solo podías hacer con contenedores o EC2: enviar la respuesta chunk por chunk, con soporte nativo para transfer-encoding: chunked. Cuando combinas esto con el streaming de React 19, obtienes una experiencia de carga que se siente instantánea incluso con lógica de servidor pesada. Este artículo es un deep dive en los detalles técnicos.

Por qué streaming cambia todo

flowchart TB
    subgraph Buffered[Respuesta bufferizada]
        B1[Request] --> B2[Fetch datos críticos]
        B2 --> B3[Fetch datos secundarios]
        B3 --> B4[Render completo]
        B4 --> B5[Enviar HTML completo]
        B5 --> B6[Usuario ve pixels]
    end

    subgraph Streamed[Respuesta streamed]
        S1[Request] --> S2[Enviar shell HTML]
        S2 --> S7[Usuario ve shell]
        S2 --> S3[Fetch datos críticos]
        S3 --> S4[Stream contenido principal]
        S4 --> S8[Usuario ve contenido]
        S4 --> S5[Fetch datos secundarios]
        S5 --> S6[Stream widgets]
        S6 --> S9[Usuario ve widgets]
    end

    style Streamed fill:#3498db,color:#fff
Enter fullscreen mode Exit fullscreen mode

El TTFB en streaming es el tiempo hasta que empieza a llegar HTML, no hasta que está todo. Esto tiene consecuencias directas en Core Web Vitals.

Los 3 límites críticos de Lambda streaming

Antes de entrar al código, hay que conocer las limitaciones:

  1. Tamaño máximo de payload: 20 MB (vs 6 MB sin streaming).
  2. Primera respuesta en 20 segundos: si no empiezas a escribir antes de 20s, Lambda te aborta.
  3. Function URLs o ALB: streaming no funciona con API Gateway v1/v2.

Lo más importante: tienes que escribir algo rápido. Si tu código hace un await grande antes del primer .write(), pierdes todo el beneficio.

El pattern correcto

Esta es la estructura que funciona:

// handler.ts
import { awslambda } from 'aws-lambda';

export const handler = awslambda.streamifyResponse(
  async (event, responseStream, context) => {
    // 1. Escribir headers y shell HTML INMEDIATAMENTE
    const metadata = {
      statusCode: 200,
      headers: {
        'Content-Type': 'text/html; charset=utf-8',
        'Cache-Control': 'no-store',
      },
    };

    responseStream = awslambda.HttpResponseStream.from(responseStream, metadata);

    // 2. Shell HTML inicial
    responseStream.write(`
      <!DOCTYPE html>
      <html>
        <head>
          <title>Loading...</title>
          <link rel="stylesheet" href="/styles.css">
        </head>
        <body>
          <div id="app">
    `);

    // 3. Datos críticos (arriba de la pliegue)
    const criticalData = await fetchCriticalData();
    responseStream.write(renderHeader(criticalData));

    // 4. Lanzar fetches paralelos
    const [sidebar, footer, recommendations] = await Promise.allSettled([
      fetchSidebar(),
      fetchFooter(),
      fetchRecommendations(),
    ]);

    // 5. Stream de cada sección según esté lista
    if (sidebar.status === 'fulfilled') {
      responseStream.write(renderSidebar(sidebar.value));
    }
    if (footer.status === 'fulfilled') {
      responseStream.write(renderFooter(footer.value));
    }
    if (recommendations.status === 'fulfilled') {
      responseStream.write(renderRecommendations(recommendations.value));
    }

    // 6. Cerrar documento
    responseStream.write(`
          </div>
          <script src="/app.js"></script>
        </body>
      </html>
    `);

    responseStream.end();
  }
);
Enter fullscreen mode Exit fullscreen mode

Integrando con React 19 renderToReadableStream

React 19 tiene renderToReadableStream que devuelve un ReadableStream. Podemos consumirlo y pipearlo al responseStream de Lambda:

import { renderToReadableStream } from 'react-dom/server';
import { awslambda } from 'aws-lambda';
import App from './App';

export const handler = awslambda.streamifyResponse(
  async (event, responseStream, context) => {
    const metadata = {
      statusCode: 200,
      headers: {
        'Content-Type': 'text/html; charset=utf-8',
      },
    };

    responseStream = awslambda.HttpResponseStream.from(responseStream, metadata);

    try {
      const stream = await renderToReadableStream(<App url={event.rawPath} />, {
        bootstrapScripts: ['/app.js'],
        onError(error) {
          console.error('React render error:', error);
        },
      });

      // Pipe del ReadableStream al Lambda stream
      const reader = stream.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        responseStream.write(decoder.decode(value));
      }

      responseStream.end();
    } catch (error) {
      console.error('Handler error:', error);
      responseStream.write('<h1>Server error</h1>');
      responseStream.end();
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Progresive rendering con Suspense

Lo mejor de este setup es combinarlo con Suspense boundaries:

// App.tsx
import { Suspense } from 'react';
import { ProductList } from './ProductList';
import { Recommendations } from './Recommendations';
import { Reviews } from './Reviews';

export default function App() {
  return (
    <html>
      <body>
        <header>
          <h1>Tienda online</h1>
        </header>

        <main>
          <Suspense fallback={<ProductListSkeleton />}>
            <ProductList />
          </Suspense>

          <Suspense fallback={null}>
            <Recommendations />
          </Suspense>

          <Suspense fallback={null}>
            <Reviews />
          </Suspense>
        </main>
      </body>
    </html>
  );
}

async function ProductList() {
  const products = await fetchProducts(); // puede tardar 300ms
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

async function Recommendations() {
  const recs = await fetchRecommendations(); // puede tardar 2s
  return <aside>{/* ... */}</aside>;
}

async function Reviews() {
  const reviews = await fetchReviews(); // puede tardar 3s
  return <section>{/* ... */}</section>;
}
Enter fullscreen mode Exit fullscreen mode

El HTML sale en este orden aproximado:

  • 0ms: <html>, <head>, <body>, header estático, skeletons.
  • 300ms: ProductList real (reemplaza su skeleton).
  • 2s: Recommendations (aparece al final del main).
  • 3s: Reviews (aparece al final del main).

El detalle del protocolo de React streaming

React usa una técnica ingeniosa: inyecta un script al final de cada chunk que mueve el contenido al lugar correcto:

<!-- Chunk 1 -->
<div id="app">
  <div hidden id="B:0"><!-- skeleton --></div>
  <div hidden id="B:1"><!-- skeleton --></div>
</div>

<!-- Chunk 2 (cuando ProductList está lista) -->
<div hidden id="S:0">
  <ul><!-- productos reales --></ul>
</div>
<script>$RC("B:0", "S:0")</script> <!-- Replace Content -->
Enter fullscreen mode Exit fullscreen mode

Este truco es lo que permite que partes del HTML se "inserten" en lugares anteriores del DOM sin JavaScript del cliente.

Manejo de errores en streaming

Si algo falla después de haber enviado headers, no puedes cambiar el status code. Hay que manejarlo de forma diferente:

export const handler = awslambda.streamifyResponse(
  async (event, responseStream, context) => {
    responseStream = awslambda.HttpResponseStream.from(responseStream, {
      statusCode: 200,
      headers: { 'Content-Type': 'text/html' },
    });

    try {
      const stream = await renderToReadableStream(<App />, {
        onError(error, errorInfo) {
          console.error('React render error:', error, errorInfo);
          // Este error solo interrumpe la rama de Suspense que falló
          // React hidrata el resto normalmente
        },
      });

      await pipeStreamToResponse(stream, responseStream);
    } catch (error) {
      // Error antes del primer render
      console.error('Fatal error:', error);
      responseStream.write('<div>Error cargando la página</div>');
    } finally {
      responseStream.end();
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Streaming con datos desde DynamoDB

Un patrón que uso mucho es stream de tablas grandes:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb';

const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export const handler = awslambda.streamifyResponse(
  async (event, responseStream, context) => {
    responseStream = awslambda.HttpResponseStream.from(responseStream, {
      statusCode: 200,
      headers: { 'Content-Type': 'application/json' },
    });

    responseStream.write('{"items":[');

    let lastKey: any = undefined;
    let firstItem = true;

    do {
      const result = await dynamo.send(
        new ScanCommand({
          TableName: 'LargeTable',
          Limit: 100,
          ExclusiveStartKey: lastKey,
        })
      );

      for (const item of result.Items ?? []) {
        if (!firstItem) responseStream.write(',');
        responseStream.write(JSON.stringify(item));
        firstItem = false;
      }

      lastKey = result.LastEvaluatedKey;
    } while (lastKey);

    responseStream.write(']}');
    responseStream.end();
  }
);
Enter fullscreen mode Exit fullscreen mode

Esto permite streamear miles de items sin cargarlos todos en memoria. Ideal para exports o APIs de reporting.

CloudFront y streaming: los headers que importan

CloudFront no maneja streaming como cualquier request. Hay que configurar:

const distribution = new cloudfront.Distribution(this, 'Dist', {
  defaultBehavior: {
    origin: new origins.FunctionUrlOrigin(functionUrl),
    viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
    // CRÍTICO: forzar HTTP/2 para streaming eficiente
    allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
    cachePolicy: new cloudfront.CachePolicy(this, 'NoCachePolicy', {
      defaultTtl: cdk.Duration.seconds(0),
      minTtl: cdk.Duration.seconds(0),
      maxTtl: cdk.Duration.seconds(0),
    }),
    // No response headers policy que agregue Content-Length
    originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
  },
  httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
});
Enter fullscreen mode Exit fullscreen mode

Clave: no pongas un ResponseHeadersPolicy que agregue Content-Length. Esto anula el chunked encoding.

Métricas reales con streaming

Comparativa de una página real:

Métrica Sin streaming Con streaming
TTFB (P50) 940 ms 85 ms
TTFB (P99) 1.8 s 140 ms
First Contentful Paint 1.4 s 320 ms
Time to Interactive 2.8 s 2.3 s
Lambda cost $0.40/1M req $0.41/1M req

El costo en Lambda es prácticamente el mismo. El beneficio es casi puro.

Limitaciones prácticas que encontré

1. Buffering en ciertos proxies.

Algunos proxies corporativos bufferean todo el HTML antes de entregarlo al cliente, matando el beneficio. No tiene solución desde servidor.

2. Testing local es complicado.

No hay una forma elegante de testear streaming localmente. Tengo un mock del runtime de Lambda que uso en Jest.

3. Request body streaming no existe todavía.

Puedes streamear la respuesta, pero el request body llega entero. Si subes archivos grandes, sigue siendo Base64 en el event.

4. El cold start se siente peor.

Paradójicamente, con streaming el cold start es más notorio porque los usuarios esperan ver pixels rápido. Considera Provisioned Concurrency si tu TTFB matters.

Cuando NO usar streaming

Si tu página:

  • Es muy pequeña (< 50KB de HTML).
  • Necesita ser indexada por bots viejos que no soportan streaming.
  • Tiene un Content-Length que necesitas para el cliente.

Probablemente no ganas nada con streaming.

Cierre

Lambda Response Streaming es la forma más barata de mejorar TTFB para cualquier app con SSR. El único costo real es pensar tu código para que empiece a escribir pronto. Combinado con React 19 y Suspense, te da una experiencia que antes requería CDNs edge costosas.

En el próximo artículo vamos a ver Astro: islas de hidratación en AWS y cómo aprovechar su modelo para sitios híbridos extremadamente rápidos.

Top comments (0)