В эпоху классического React серверный рендеринг опирался на синхронный метод renderToString. Его архитектурная модель страдала от фатального недостатка буферизации по принципу «все или ничего»: сервер был обязан дождаться ответов всех баз данных и микросервисов для каждого компонента страницы, синхронно сформировать гигантскую строковую переменную в оперативной памяти Node.js и только затем отдать первый байт клиенту. Если один второстепенный блок зависал на секунду, пользователь и поисковый бот наблюдали пустой экран, а метрика TTFB (Time to First Byte) деградировала.
React 19 закрепляет парадигму потокового серверного рендеринга (Streaming SSR) в качестве стандарта:
Потоковый SSR устраняет ключевые системные узкие места традиционного серверного рендеринга:
head, не простаивая в ожидании готовности серверных данных.<Suspense> не приводит к HTTP 500 для всей страницы: сервер отображает локальный fallback, сохраняя рабочий интерфейс для пользователя.Ключевой концепт потокового SSR — разделение страницы на критическую оболочку (Shell) и асинхронные островки, изолированные границами <Suspense>.
Сервер не ждет готовности внутренних промисов. Как только внешняя разметка (Shell) готова, срабатывает коллбэк onShellReady. Сервер немедленно отправляет заголовки HTTP 200 и каркас со скелетонами.
Когда медленный асинхронный компонент завершает получение данных из базы, React дописывает в этот же открытый HTTP-поток фрагмент настоящего HTML и крошечный скрипт $RC("S:0","H:0") (React Replace Content). Браузер исполняет этот скрипт мгновенно прямо в процессе потоковой загрузки HTML, подменяя скелетон готовым интерфейсом до того, как загрузится основной клиентский JS-бандл!
Сервер вызывает renderToPipeableStream. Движок начинает обход синхронного дерева до границ Suspense.
Генерируется базовая верстка со скелетонами. Сервер отправляет заголовки HTTP 200 и подключает pipe(res).
Браузер пользователя получает заголовок страницы и рисует интерактивный каркас (FCP менее 40 мс).
Сервер получает данные из БД, формирует скрытый HTML-блок и инжектирует управляющий скрипт $RC.
Скрипт $RC аппаратно подменяет скелетон реальным контентом в DOM еще до загрузки React-бандла.
Каким образом браузер отображает реальный HTML-контент на месте скелетона при потоковом SSR до того, как клиентский JavaScript-бандл React загрузится и начнет гидратацию?
Мгновенная отдача статического каркаса и шапки каталога за 20 мс, в то время как сложный расчет персональных скидок, рекомендаций и остатков на складах досылается потоковыми чанками.
Автоматическое переключение логики потока по заголовку User-Agent: обычные пользователи получают мгновенный отклик через onShellReady, а поисковые краулеры ждут полного разрешения через onAllReady для индексации монолитного документа.
Генерация первого байта в непосредственной близости к пользователю (в радиусе 10 мс) через renderToReadableStream без задержек холодного старта традиционных виртуальных машин.
Принудительное прерывание зависших серверных запросов по дедлайну (abort()) с автоматическим выводом клиентских фолбэков без падения всего соединения.
Сервер вызывает renderToPipeableStream. Движок рендерит синхронное дерево до ближайших границ Suspense.
Оболочка готова. Сервер выставляет HTTP 200, отдаёт заголовки и пайпит поток в сетевой сокет (pipe(res)).
Серверные промисы внутри Suspense завершаются. React генерирует скрытые контейнеры с данными и инлайн-скрипт замены.
Все границы обработаны. React отправляет завершающий нулевой чанк и закрывает HTTP-соединение.
Функция renderToPipeableStream принимает объект конфигурации со строгим набором коллбэков:
| Коллбэк | Условие срабатывания | Предназначение в архитектуре |
| :--- | :--- | :--- |
| onShellReady | Готов каркас документа (все, что вне <Suspense>) | Немедленный сброс HTTP 200 для реальных пользователей; старт pipe(res) |
| onAllReady | Все Suspense-границы завершили загрузку | Отправка ответа поисковым краулерам (ботам) для индексации полного HTML |
| onShellError | Ошибка возникла внутри критического каркаса | Отправка статуса HTTP 500 и запасной статической страницы сбоя |
| onError | Ошибка в любой точке дерева (включая Suspense) | Централизованное логирование в системы мониторинга (Sentry, OpenTelemetry) |
Ниже представлен продакшн-сервер, реализующий дифференциацию пользователей и краулеров, контроль таймаута через abort() и обработку противодавления (Backpressure):
import express, { Request, Response } from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import React from 'react';
import App from '../client/App';
const app = express();
// Проверка на поисковых краулеров (SEO bots)
const BOT_AGENTS_REGEX = /Googlebot|bingbot|Baiduspider|DuckDuckBot|YandexBot|Sogou|Slurp/i;
function isCrawler(userAgent: string | undefined): boolean {
if (!userAgent) return false;
return BOT_AGENTS_REGEX.test(userAgent);
}
app.get('*', (req: Request, res: Response) => {
let didError = false;
const isBot = isCrawler(req.headers['user-agent']);
// Тайм-аут принудительного прерывания зависших запросов (5 секунд)
const ABORT_DELAY_MS = 5000;
const { pipe, abort } = renderToPipeableStream(
<App url={req.url} />,
{
bootstrapScripts: ['/assets/bundle.client.js'],
onShellReady() {
// ДЛЯ ОБЫЧНЫХ ПОЛЬЗОВАТЕЛЕЙ: отдаем первый байт мгновенно!
if (!isBot) {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
// Подключение потока к сокету Express с контролем Backpressure
pipe(res);
}
},
onAllReady() {
// ДЛЯ ПОИСКОВЫХ БОТОВ: ждем пока загрузится абсолютно весь Suspense-контент
if (isBot) {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
}
},
onShellError(error: unknown) {
// Критический сбой каркаса: отдаем статус 500 и резервный HTML
console.error('[Shell Error]:', error);
res.status(500).send(
'<!DOCTYPE html><html><head><title>Ошибка</title></head><body><h1>Критический сбой сервиса</h1></body></html>'
);
},
onError(error: unknown) {
didError = true;
// Логирование сбоев во внутренних Suspense-блоках (поток не падает)
console.error('[Suspense Render Error]:', error);
},
}
);
// Сторожевой таймер: прерываем рендеринг, если БД не ответила вовремя
setTimeout(() => {
abort();
}, ABORT_DELAY_MS);
});
app.listen(3000, () => {
console.log('Потоковый SSR сервер запущен на http://localhost:3000');
});В бессерверных средах (Cloudflare Workers, Vercel Edge Runtime) используется стандарт W3C Web Streams. Метод renderToReadableStream возвращает промис, разрешающийся инстансом ReadableStream:
import { renderToReadableStream } from 'react-dom/server.edge';
import React from 'react';
import App from '../client/App';
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const controller = new AbortController();
// Защитный тайм-аут для Edge Worker (3 секунды)
const timeoutId = setTimeout(() => {
controller.abort();
}, 3000);
try {
const stream = await renderToReadableStream(
<App url={url.pathname} />,
{
signal: controller.signal,
bootstrapScripts: ['/assets/bundle.client.js'],
onError(error: unknown) {
console.error('[Edge Streaming Error]:', error);
},
}
);
clearTimeout(timeoutId);
// Возврат нативного Response со стримом тела
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-transform',
},
});
} catch (err: unknown) {
clearTimeout(timeoutId);
console.error('[Edge Shell Fatal]:', err);
return new Response('<h1>Внутренняя ошибка Edge-рендеринга</h1>', {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
},
};Почему отправка ответа в коллбэке onAllReady вместо onShellReady является лучшей практикой именно для поисковых краулеров (Googlebot)?
Для унификации серверных точек входа создадим готовый хелпер, инкапсулирующий логику стриминга, расчет TTFB и обработку краулеров:
import { Response } from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import React from 'react';
interface StreamingOptions {
component: React.ReactElement;
bootstrapScripts: string[];
userAgent?: string;
timeoutMs?: number;
onMetrics?: (ttfbMs: number, totalMs: number) => void;
}
export function createStreamingResponse(
res: Response,
options: StreamingOptions
): void {
const {
component,
bootstrapScripts,
userAgent,
timeoutMs = 6000,
onMetrics,
} = options;
const startTime = performance.now();
let ttfbRecorded = false;
let didError = false;
const isCrawler = Boolean(
userAgent && /Googlebot|bingbot|YandexBot|Baiduspider/i.test(userAgent)
);
const { pipe, abort } = renderToPipeableStream(component, {
bootstrapScripts,
onShellReady() {
if (!isCrawler) {
const ttfb = performance.now() - startTime;
ttfbRecorded = true;
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
if (onMetrics) {
onMetrics(ttfb, 0);
}
}
},
onAllReady() {
const totalTime = performance.now() - startTime;
if (isCrawler) {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
}
if (onMetrics && ttfbRecorded) {
onMetrics(0, totalTime);
}
},
onShellError(err: unknown) {
console.error('[Streaming Shell Critical]:', err);
res.status(500).send('<!DOCTYPE html><html><body>Server Error</body></html>');
},
onError(err: unknown) {
didError = true;
console.error('[Streaming Inner Warning]:', err);
},
});
// Предотвращение зависания сокета
setTimeout(() => {
abort();
}, timeoutMs);
}Самая распространенная архитектурная ошибка при внедрении renderToPipeableStream — ожидание onAllReady перед вызовом pipe(res) для обычных пользователей. Это полностью аннулирует все преимущества потокового рендеринга, заставляя пользователя ждать завершения самого медленного запроса, и возвращает приложение к недостаткам устаревшего renderToString.
// ❌ ПЛОХО: убийство преимуществ стриминга, пользователь ждет самый медленный запрос
renderToPipeableStream(<App />, {
onAllReady() {
pipe(res); // TTFB взлетает до 1.5 секунд!
}
});
// ✅ ПРАВИЛЬНО: немедленная отдача каркаса в onShellReady
renderToPipeableStream(<App />, {
onShellReady() {
pipe(res); // TTFB составляет 20-30 мс!
}
});Если внешний ресурс (сервис рекомендаций или база данных) зависает из-за деградации сети, а в сервере не настроен сторожевой таймер с вызовом abort(), HTTP-соединение с клиентом зависает в открытом состоянии навсегда. Это приводит к утечке сокетов, исчерпанию файловых дескрипторов ОС и отказу всего сервера под нагрузкой.
Если тяжелый асинхронный компонент помещен в корень дерева без обрамления в границу <Suspense>, движок React относит его к критической оболочке (Shell). Событие onShellReady не наступит до тех пор, пока этот компонент не завершит свою работу, что блокирует отправку начальных HTML-заголовков.
При падении рендеринга на этапе сборки Shell (например, опечатка в пропсах критического компонента) срабатывает onShellError, а onShellReady не вызывается вовсе. Если в коде не реализован onShellError, сервер не вернет клиенту никакого ответа, и браузер упадет по сетевому таймауту с ошибкой ERR_EMPTY_RESPONSE.