Реалізація Streaming SSR для веб-застосунку
Класичний SSR блокує: сервер чекає всіх даних, рендерить повний HTML, відправляє. Поки найповільніший запит до бази не завершиться — браузер не отримає жодного байта. Streaming SSR розриває цей блок: HTML видається браузеру по мірі готовності частин сторінки, через HTTP chunked transfer encoding.
Браузер починає парсити та показувати HTML миттєво. Повільні частини з'являються пізніше. Користувач видить щось на екрані за 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, алертинг на повільні chunks







