Реализация Streaming SSR для веб-приложения
Классический SSR блокирует: сервер ждёт всех данных, рендерит полный HTML, отправляет. Пока самый медленный запрос к базе не завершится — браузер не получит ни байта. Streaming SSR разрывает этот блок: HTML отдаётся браузеру по мере готовности частей страницы, через HTTP chunked transfer encoding.
Браузер начинает парсить и отображать HTML немедленно. Slow parts появляются позже. Пользователь видит что-то на экране за 100–200ms даже если полный рендер занимает секунду.
Механика Streaming SSR
HTTP/1.1 поддерживает Transfer-Encoding: chunked — сервер отправляет данные частями без заголовка Content-Length. React и Vue используют это для стриминга:
Клиент получает:
[Chunk 1 — 50ms] <html><head>...</head><body><header>...</header>
[Chunk 2 — 120ms] <main><nav>...</nav><div id="products-suspense"><!--$?--><template id="B:0">...skeleton...</template>
[Chunk 3 — 340ms] <div hidden id="S:0"><ul><li>Товар 1</li>...<!-- Данные готовы --></ul></div><script>$RC("B:0","S:0")</script>
[Chunk 4 — 890ms] <aside><!-- Рекомендации --></aside></main></body></html>
Браузер рендерит каждый chunk сразу. $RC — скрипт React для замены skeleton на реальный контент.
Реализация в React 18 + Next.js
// app/catalog/page.tsx
import { Suspense } from 'react';
// Намеренно медленный компонент — симулирует запрос к внешнему API
async function FeaturedProducts() {
// Медленный внешний сервис — 800ms
const products = await fetch('https://api.example.com/featured', {
next: { revalidate: 300 }
}).then(r => r.json());
return (
<ul>
{products.map((p: Product) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
async function Categories() {
// Быстрый внутренний запрос — 50ms
const cats = await db.category.findMany();
return <nav>{cats.map(c => <a key={c.id} href={`/catalog/${c.slug}`}>{c.name}</a>)}</nav>;
}
export default function CatalogPage() {
return (
<div>
{/* Рендерится немедленно — синхронный */}
<h1>Каталог</h1>
{/* Быстрый блок — появится за ~50ms */}
<Suspense fallback={<CategoriesSkeleton />}>
<Categories />
</Suspense>
{/* Медленный блок — появится за ~800ms, остальное не блокирует */}
<Suspense fallback={<ProductsGridSkeleton count={6} />}>
<FeaturedProducts />
</Suspense>
</div>
);
}
Параллельный фетчинг данных
Критическая ошибка — последовательные await (waterfall):
// Плохо: 200ms + 500ms + 300ms = 1000ms ожидания
async function Page() {
const user = await getUser(); // 200ms
const orders = await getOrders(user.id); // 500ms
const stats = await getStats(user.id); // 300ms
...
}
// Хорошо: max(200ms, 500ms, 300ms) = 500ms
async function Page() {
const userPromise = getUser();
const [user, orders, stats] = await Promise.all([
userPromise,
userPromise.then(u => getOrders(u.id)),
userPromise.then(u => getStats(u.id)),
]);
...
}
Ещё лучше — разнести независимые данные по разным Suspense-компонентам, чтобы они стримились параллельно без единого Promise.all:
export default function DashboardPage() {
// Все три запроса запускаются параллельно
// Каждый блок появляется как только его данные готовы
return (
<main>
<Suspense fallback={<UserCardSkeleton />}>
<UserCard /> {/* getUser() — 200ms */}
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders /> {/* getOrders() — 500ms */}
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats /> {/* getStats() — 300ms */}
</Suspense>
</main>
);
}
Стриминг в Node.js без фреймворка
Для кастомных серверов — renderToPipeableStream:
import { renderToPipeableStream } from 'react-dom/server';
import { createServer } from 'http';
createServer((req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
bootstrapScripts: ['/static/js/main.js'],
onShellReady() {
// Shell готов (контент до первого Suspense) — начинаем стриминг
res.statusCode = 200;
pipe(res);
},
onShellError(error) {
// Shell не рендерится — fallback к CSR
res.statusCode = 500;
res.end('<html><body><div id="root"></div></body></html>');
},
onError(error) {
console.error('Streaming error:', error);
},
});
// Таймаут для очень медленных компонентов
setTimeout(() => abort(), 10000);
}).listen(3000);
Edge Streaming с Cloudflare Workers
// worker.ts
import { renderToReadableStream } from 'react-dom/server';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const stream = await renderToReadableStream(
<App url={url.pathname} env={env} />,
{
bootstrapScripts: ['/main.js'],
onError: console.error,
}
);
// Ждём shell (критический путь) но не ждём Suspense
await stream.allReady; // Опционально: ждать всего для ботов
return new Response(stream, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
},
};
Стриминг метаданных и заголовков
Проблема стриминга: <head> отправляется первым, до готовности данных. Метаданные из асинхронных компонентов нужно передавать отдельно:
// Next.js решает через generateMetadata — выполняется отдельно от рендера страницы
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await getProduct(params.id);
return {
title: product.name,
description: product.description,
};
}
// generateMetadata и Page компонент выполняются параллельно
// Стриминг начинается как только generateMetadata завершится
Loading UI и skeleton-стратегии
app/
catalog/
loading.tsx # Автоматический Suspense fallback для всего маршрута
page.tsx
// app/catalog/loading.tsx — рендерится пока page.tsx загружает данные
export default function CatalogLoading() {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="animate-pulse bg-gray-200 rounded-lg h-64" />
))}
</div>
);
}
Метрики влияния Streaming SSR
| Метрика | Без стриминга | Со стримингом |
|---|---|---|
| TTFB | После рендера (500–1500ms) | До рендера (< 100ms) |
| FCP | После TTFB + парсинг | Сразу после shell |
| LCP | Зависит от медленных данных | Зависит только от нужного блока |
| INP | Блокируется до гидратации | Гидратация начинается раньше |
Сроки реализации
- Неделя 1–2: аудит текущего SSR, выявление медленных запросов, рефакторинг под Suspense-границы
- Неделя 3: параллелизация Promise.all и независимые Suspense-блоки
- Неделя 4: skeleton UI для всех Suspense fallback, loading.tsx для маршрутов
- Неделя 5: измерение Core Web Vitals до/после, оптимизация shell-времени
- Неделя 6: мониторинг стриминга в production, алертинг на медленные чанки







