Suspense 在 shell 之后抛错,Nginx 记的却是 200
线上有过这么一次事故:某个 Suspense 边界内的服务端数据源在 shell 已经 flush 之后才抛错。流已经打开,响应头已经发出,Nginx 记录的是 200;浏览器端拿到的是那个区域内的错误 UI,监控按 200 收告警,这一条故障从告警里直接消失了。
这不是状态码配置错了,而是流式 SSR 的错误时序本来如此:状态码的有效期只到 shell 发出的那一刻。
React 18 之前的 SSR 基本是 renderToString:整棵组件树在内存里渲染成完整 HTML,再一次性发出去。任何一个慢数据源都会拖住整页,哪怕首屏只是等一个评论区的数据。
Streaming SSR 的顺序反过来:先输出不需要等待的 shell(Suspense 边界之外的 head、导航、布局、loading fallback),HTTP 流保持打开;每个 Suspense 边界的数据 resolve 后,再把对应 HTML chunk 追加进同一条流,并内联一段 `` 把 fallback 替换成真实内容。整个过程没有额外请求。
服务端入口也有两条路:
- Node.js(Express/Fastify):
renderToPipeableStream,返回 Node.js Readable。 - Web Streams 环境(Cloudflare Workers / Deno / Bun / edge):
renderToReadableStream,返回 ReadableStream。Next.js App Router 在 Node 侧用前者,edge 侧用后者。
拆页面时最有用的判断是:什么内容必须出现在首屏,放 shell;什么内容可以等数据,放 Suspense 边界。Suspense 边界就是流的“缝合线”。
状态码的窗口,说穿了也只有一句话:
- shell 还没发时的错误走
onShellError:此时响应头还没写,还能res.statusCode = 500。 - shell 发出之后的 Suspense chunk 错误走
onError:流已经以 200 发出,服务端改不了码,只能靠客户端 Error Boundary 兜住这一块。
严格说 onError 也会在 shell 发出之前触发,所以代码里常用一个 didError 标志,在 onShellReady 里决定返回 500 还是 200。真正的分水岭不是回调名,而是响应体是否已经写出。
最小骨架是这样:
const ABORT_DELAY = 10_000;
app.get('/article/:id', (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
{/* 一个异步区域 = 一个 Suspense 边界 + 一个客户端 Error Boundary */}
}>
}>
,
{
onShellReady() {
// 这是设置 header 的最后机会,pipe 之后 setHeader 无效
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
},
onShellError(error) {
// shell 本身渲染失败,响应还没发出,可以返回 500
console.error(error);
res.statusCode = 500;
res.send('服务端渲染失败
');
},
onError(error) {
// 渲染过程中的错误日志回调;shell 已经发出后走到这里时,
// 状态码已经不可变,不要再尝试 setHeader
didError = true;
console.error('Suspense chunk error:', error);
},
}
);
// 某个 Suspense 数据源迟迟不 resolve 时,终止 SSR,
// 该区域留给客户端做 CSR 兜底
setTimeout(() => abort(), ABORT_DELAY);
});
所以服务端错误处理和客户端错误边界是两件事,不能互相替代。onError 记录日志没问题,但用户已经拿到的那个 200 页面里,总得有一个 Error Boundary 决定这一段显示什么。凡是异步数据的区域,Suspense 和 ErrorBoundary 都成对出现。
有些站点还要考虑爬虫。普通用户要渐进流,但不少爬虫不认流式追加,直接把收到的 body 当最终 HTML 存下来。首屏只有骨架的话,对它们就是空页面。这时要在 onAllReady 回调里才 pipe(res),把包含所有 Suspense chunk 的完整 HTML 一次性交给爬虫。代价是爬虫的响应会被最慢的那个数据源拖住。
没有边缘部署需求就用 renderToPipeableStream;不需要给爬虫完整 HTML 的话,也不必等 onAllReady,等它就等于把首屏优化又让渡回最慢接口了。
还有一个反直觉的现象:同一个页面第二次访问,HTML 很可能一次性返回,不再分段。第一次请求会把 Suspense children 的 JS 模块加载进 Node 进程的模块缓存,第二次加载这些模块是同步过程,整个渲染同步完成,自然没有可等待的异步边界。别因为第二次访问没分段,就断定流式坏了。
hydration 对树的结构有硬约束:客户端 React 树必须在每一个 Suspense 边界上与服务端渲染出的 HTML 对齐。如果由于 client-only 条件,某个 Suspense 边界只在浏览器端出现,或者挂在了不同位置,就会报 hydration mismatch。不要让 Suspense 边界的位置由 client-only state 控制。
abort 超时是流式渲染的收尾闸门。没有它,一个不 resolve 的数据源会让 HTTP 连接被无限占用。setTimeout(() => abort(), ABORT_DELAY) 到点后终止 SSR,该区域自然降级到客户端渲染,shell 不受影响。