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
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:
- Tamaño máximo de payload: 20 MB (vs 6 MB sin streaming).
- Primera respuesta en 20 segundos: si no empiezas a escribir antes de 20s, Lambda te aborta.
- 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();
}
);
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();
}
}
);
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>;
}
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 -->
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();
}
}
);
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();
}
);
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,
});
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)