В епоху класичного 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.