Streaming SSR con Suspense en React: qué pasa con los error boundaries cuando el render ya no es todo o nada
El SSR tradicional tenía una propiedad que simplificaba mucho el manejo de errores: o se generaba el HTML completo, o no se enviaba nada. renderToString corre de forma sincrónica, de arriba a abajo, y si algo en el árbol lanza una excepción, el error llega intacto a nuestro código antes de que un solo byte haya salido por la red. Esa propiedad desaparece con el streaming SSR que habilita Suspense, y no es un detalle de implementación: cambia qué puede hacer un error boundary y en qué momento puede hacerlo.
Lo que dábamos por sentado con SSR síncrono
Con renderToString o renderToStaticMarkup, el patrón habitual era envolver el render completo en un try/catch y decidir recién ahí qué responder:
try {
const html = renderToString(<App />);
res.status(200).send(wrapInDocument(html));
} catch (err) {
logError(err);
res.status(500).send(renderErrorPage());
}
Un solo error boundary en la raíz de <App /> era suficiente para cualquier error de render, porque no había nada enviado todavía que proteger. La decisión de status code -200 o 500- se tomaba con el árbol completo ya resuelto en memoria. No existía el problema de "ya mandamos la mitad de la respuesta": o había una respuesta completa, o había un error que podíamos interceptar antes de que el cliente viera absolutamente nada.
Qué introduce renderToPipeableStream
El streaming SSR invierte esa secuencia. En vez de esperar a que todo el árbol esté resuelto, React envía primero un "shell" -la parte de la UI que no depende de datos asíncronos- y va completando el resto con chunks adicionales a medida que cada límite Suspense resuelve su promesa. Cada chunk llega como HTML seguido de un pequeño script inline que reemplaza el fallback correspondiente en el DOM.
const { pipe, abort } = renderToPipeableStream(<App />, {
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(err) {
logError(err);
res.statusCode = 500;
res.send(renderErrorPage());
},
onError(err) {
didError = true;
logError(err);
},
onAllReady() {
// útil para crawlers o pre-render; no lo usamos en el camino normal
},
});
La distinción entre onShellError y onError es la que concentra todo el cambio de contrato. onShellError se dispara cuando el error ocurre antes de que el shell esté listo para enviarse: en ese punto todavía no mandamos nada, así que podemos actuar igual que en el modelo síncrono, con status 500 y una página de error completa. onError se dispara para cualquier error que ocurra después, dentro de un límite Suspense que ya forma parte de una respuesta cuyo encabezado y cuyo shell ya salieron por el socket. Para ese momento, el status code de la respuesta ya fue decidido y enviado. onError no puede cambiarlo. Solo puede registrar el error.
Qué pasa con un chunk que falla después de que otros ya se enviaron
Supongamos una página con tres secciones independientes, cada una en su propio límite Suspense: encabezado con datos del usuario, cuerpo con el contenido principal, barra lateral con recomendaciones. El shell se envía con los tres fallbacks visibles. Si la sección de recomendaciones falla al resolver su dato, hay dos caminos posibles, y cuál se toma depende de algo que antes no importaba: si ese límite Suspense tiene un error boundary propio envolviéndolo.
Con un error boundary dedicado a esa sección, React captura el error, descarta el intento de esa parte del árbol y transmite un chunk de reemplazo: el fallback original de esa sección se sustituye por la UI de error del boundary, sin tocar el encabezado ni el cuerpo que ya se renderizaron bien.
<Sidebar>
<ErrorBoundary fallback={<RecomendacionesIndisponibles />}>
<Suspense fallback={<SidebarSkeleton />}>
<Recomendaciones />
</Suspense>
</ErrorBoundary>
</Sidebar>
Sin ese error boundary local, el error sube buscando el próximo límite que sí tenga uno -en la práctica, muchas veces termina en la raíz, si es que hay alguno ahí. El problema es que, para ese momento, la raíz ya se envió como parte del shell. React no puede "deshacer" el HTML ya transmitido para mostrar una página de error distinta. Lo que ocurre en la práctica es que la sección afectada queda mostrando su fallback de carga de forma indefinida en el HTML que llegó por streaming, y del lado del cliente, cuando React hidrata, vuelve a intentar renderizar esa parte -a veces logra recuperarse si el error fue transitorio, a veces repite el mismo error ahora en el cliente. Ninguno de los dos casos es un error 500 limpio: la respuesta ya se envió con status 200, y lo único que queda es lo que capturemos en onError para registro interno.
Lo que tuvimos que rediseñar
Teníamos una página -un dashboard con varios widgets independientes, cada uno con su propia fuente de datos- construida sobre el supuesto de SSR síncrono: un único error boundary en la raíz, un único try/catch, y una regla simple en el pipeline de monitoreo que alertaba sobre cualquier respuesta 500. Al migrar a streaming, esa regla de alertas dejó de servir para detectar fallos de los widgets individuales, porque un widget roto después del shell ya no generaba un 500: generaba un 200 con una sección rota adentro, invisible para cualquier chequeo que solo mirara el status code de la respuesta.
Lo primero que cambiamos fue la granularidad de los error boundaries: pasamos de uno en la raíz a uno por cada widget que tuviera su propio límite Suspense, siguiendo la misma idea que separa encabezado, cuerpo y sidebar en el ejemplo anterior. Esto evita que un fallo en un widget se lleve por delante el resto de la página, que era justamente el comportamiento que queríamos conservar.
Lo segundo fue dejar de confiar en el status code HTTP como señal de fallo para cualquier error que no fuera previo al shell. Agregamos una llamada explícita a nuestro sistema de monitoreo dentro de cada error boundary -tanto en el método de React (componentDidCatch) como en el callback onError del propio renderToPipeableStream- para que un fallo parcial se registrara igual que antes, aunque el status de la respuesta siguiera siendo 200.
class WidgetErrorBoundary extends React.Component {
componentDidCatch(error, info) {
reportToMonitoring({ error, widget: this.props.widgetName, info });
}
render() {
if (this.state?.hasError) return this.props.fallback;
return this.props.children;
}
}
Lo tercero fue revisar qué contenido poníamos como fallback de cada Suspense. Antes el fallback era solo un estado transitorio de carga que desaparecía en milisegundos. Ahora, con la posibilidad real de que un fallback quede visible de forma indefinida si no hay recuperación en el cliente, diseñamos cada fallback para que fuera una UI aceptable de quedarse ahí: sin mensajes que prometieran "cargando" sin fecha de vencimiento, y con una acción manual de reintento en los casos donde tenía sentido, en vez de depender exclusivamente de que la hidratación del cliente lo resolviera sola.
Por último, mantuvimos el shell deliberadamente libre de dependencias de datos asíncronos propias, no solo de los widgets. Cualquier cosa que pudiera fallar durante la generación del shell -antes de onShellReady- sigue cayendo en onShellError, donde todavía podemos decidir el status code y mostrar una página de error real. Cuanto más delgado y menos dependiente de datos es el shell, menos casos terminan resolviéndose en el camino sin red de seguridad que es onError.
Qué revisamos antes de mover una página a streaming SSR
Antes de activar renderToPipeableStream en una página que antes usaba render síncrono, confirmamos que cada sección con su propio límite Suspense tenga también su propio error boundary, porque un boundary único en la raíz ya no protege nada una vez que el shell salió por streaming. Revisamos que ningún chequeo de monitoreo dependa solo del status code HTTP para detectar fallos parciales, y agregamos registro explícito dentro de cada boundary para cubrir justamente los casos que un 200 ya no puede delatar. Diseñamos cada fallback asumiendo que puede quedar visible de forma indefinida, no solo unos milisegundos. Y mantenemos el shell sin dependencias de datos propias, para que la mayoría de los errores reales sigan cayendo en onShellError, el único punto donde todavía tenemos control completo sobre la respuesta.