У традиційній архітектурі буферизованого Server-Side Rendering (SSR) існувало критичне вузьке місце: сервер був зобов'язаний повністю дочекатися завершення всіх асинхронних операцій (запитів до баз даних, мікросервісів та сторонніх API) перед тим, як надіслати хоча б один байт згенерованого HTML клієнту.
Якщо на сторінці 95% контенту статичні й готові до показу за 20 мс, але один другорядний блок (наприклад, персоналізовані рекомендації або стрічка активності) відповідає 2 секунди, користувач бачитиме порожній білий екран цілих 2 секунди. Це різко погіршує фундаментальні метрики продуктивності Web Vitals:
Streaming SSR та React Suspense усувають це монолітне блокування. Сервер миттєво відправляє статичну оболонку сторінки (Shell), навігаційну панель та скелетони завантаження, а повільні компоненти стрімляться в браузер у міру їх готовності по відкритому мережевому з'єднанню.
При потоковому рендерингу документ більше не є єдиним статичним файлом із фіксованим розміром Content-Length. Натомість протокол перемикається в режим Chunked Transfer Encoding (або потокову передачу кадрів у HTTP/2 та HTTP/3).
Сервер розділяє дерево інтерфейсу на дві категорії:
layout.tsx), навігація, футер та резервні скелетони блоків. Відправляється клієнту за мінімальний час (менше 30–50 мс).HTTP-з'єднання (Transfer-Encoding: chunked / HTTP/2)
────────────────────────────────────────────────────────────────────────────►
[ЧАНК 1: Каркас сторінки (Shell) + Заглушки]
<!DOCTYPE html><html>
<body>
<nav>Меню сайту</nav>
<div id="slot-reviews">
<!--$?--><template id="B:0"></template><div class="skeleton">Завантаження відгуків...</div><!--/$-->
</div>
</body>
</html>
────────────────────────────────────────────────────────────────────────────► Браузер малює FCP!
... (Сервер паралельно виконує повільний запит до БД reviewsDb.findMany()) ...
[ЧАНК 2: Готові дані + Скрипт підміни в DOM]
<div hidden id="S:0">
<div class="reviews-list">Відгук 1, Відгук 2, Відгук 3</div>
</div>
<script>
$RC("B:0", "S:0"); // Внутрішня функція React: атомарно замінює B:0 на S:0
</script>
────────────────────────────────────────────────────────────────────────────► Скелетон миттєво
замінюється контентом!Сервер починає виконання маршруту і негайно відправляє заголовок із Transfer-Encoding: chunked, теги <html>, <head> та статичну оболонку сторінки.
Браузер отримує перший чанк і миттєво відмальовує навігацію та плейсхолдери скелетонів. TTFB зафіксовано на мінімальному значенні.
Сервер паралельно виконує незалежні асинхронні запити до БД для компонентів, ізольованих межами Suspense.
У міру готовності кожного компонента сервер відправляє його розмітку та мікроскрипт $RC(), який нативно заміщує скелетон на готові елементи.
React активує інтерактивність клієнтських компонентів окремо без необхідності чекати завантаження решти модулів сторінки.
Яким чином браузер підставляє готовий HTML потокового компонента в потрібне місце DOM-дерева, якщо цей чанк прийшов у самому кінці незакритої HTTP-відповіді?
Файл конвенції loading.tsx автоматично огортає сусідній page.tsx (і всі його дочірні вузли) в кореневу межу <Suspense fallback={<Loading />}>:
export default function AnalyticsLoading() {
return (
<div className="p-8 space-y-6 animate-pulse">
<div className="h-8 w-64 bg-bg-elevated rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="h-32 bg-bg-elevated rounded-xl border border-border" />
<div className="h-32 bg-bg-elevated rounded-xl border border-border" />
<div className="h-32 bg-bg-elevated rounded-xl border border-border" />
</div>
<div className="h-80 bg-bg-elevated rounded-xl border border-border" />
</div>
);
}Використання лише loading.tsx призводить до того, що вся сторінка чекає завершення найповільнішого компонента. Для максимальної паралелізації використовуйте незалежні теги <Suspense>:
import { Suspense } from 'react';
import { MetricsOverview } from '@/components/MetricsOverview';
import { RealtimeTrafficChart } from '@/components/RealtimeTrafficChart';
import { TransactionFeed } from '@/components/TransactionFeed';
import { CardSkeleton, ChartSkeleton, FeedSkeleton } from '@/components/skeletons';
export default function AnalyticsPage() {
return (
<main className="p-8 space-y-8 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold text-text-primary">Панель моніторингу</h1>
{/* 1. Блок метрик: відповідає швидко (~100 мс) */}
<Suspense fallback={<CardSkeleton />}>
<MetricsOverview />
</Suspense>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 2. Важкий графік: агрегація в ClickHouse (~800 мс) */}
<div className="lg:col-span-2">
<Suspense fallback={<ChartSkeleton />}>
<RealtimeTrafficChart />
</Suspense>
</div>
{/* 3. Жива стрічка транзакцій: звернення до зовнішнього API (~400 мс) */}
<div className="lg:col-span-1">
<Suspense fallback={<FeedSkeleton />}>
<TransactionFeed />
</Suspense>
</div>
</div>
</main>
);
}use())#У React 19 через межу Suspense можна передавати нерозв'язані об'єкти Promise. Серверний компонент запускає запит без await, а клієнтський компонент розгортає дані безпосередньо через хук use():
import { Suspense } from 'react';
import { CustomerReviews } from './CustomerReviews';
import { getReviewsPromise } from '@/lib/db/reviews';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function ProductPage({ params }: PageProps) {
const { id } = await params;
// Ініціюємо запит БЕЗ await: проміс починає резолвитися у фоновому стрімі
const reviewsPromise = getReviewsPromise(id);
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold">Картка товару #{id}</h1>
<Suspense fallback={<div className="h-40 bg-bg-elevated rounded-lg animate-pulse" />}>
{/* Передаємо нерозв'язаний Promise клієнту */}
<CustomerReviews promise={reviewsPromise} />
</Suspense>
</div>
);
}'use client';
import { use } from 'react';
interface Review {
id: string;
rating: number;
text: string;
}
export function CustomerReviews({ promise }: { promise: Promise<Review[]> }) {
// Хук use() перехоплює проміс та інтегрується з батьківським Suspense
const reviews = use(promise);
return (
<div className="border border-border rounded-xl p-6 bg-bg-surface space-y-4">
<h2 className="text-lg font-bold">Відгуки покупців ({reviews.length})</h2>
<div className="divide-y divide-border">
{reviews.map((r) => (
<div key={r.id} className="py-3">
<span className="text-accent-cyan font-semibold">Оцінка: {r.rating}/5</span>
<p className="text-text-muted mt-1">{r.text}</p>
</div>
))}
</div>
</div>
);
}connection()#У Next.js 15+ для запобігання випадковому виконанню динамічного коду на етапі статичного пререндерингу збирача використовується утиліта connection():
import { connection } from 'next/server';
import { redis } from '@/lib/redis';
export async function LiveUserCounter() {
// Явно дочікуємося встановлення вхідного клієнтського з'єднання.
// Це повідомляє Next.js, що компонент повинен виконуватися суворо в стрімі при запиті!
await connection();
const activeUsers = await redis.get('online_users_count');
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-success/10 border border-success/20 text-success text-xs font-mono">
<span className="w-2 h-2 rounded-full bg-success animate-ping" />
Онлайн зараз: {activeUsers ?? 0}
</div>
);
}Що відбудеться, якщо користувач натисне кнопку 'Купити' всередині клієнтського компонента, JS-чанк якого ще не встиг завантажитися через повільне мобільне 3G з'єднання?
Якщо ви виконаєте асинхронний виклик await getGlobalSettings() прямо в тілі app/layout.tsx або app/page.tsx без обгортки в Suspense, сервер не зможе відправити браузеру навіть початковий тег <html> та заголовок <head>!
Уся сторінка перейде в режим блокуючого буферизованого SSR, а параметр TTFB збільшиться на час найповільнішого запиту.
Рішення: Залишайте кореневі макети максимально легковажними та синхронними. Усі асинхронні вибірки даних переносьте глибше в незалежні компоненти, обгорнуті в <Suspense fallback={<Skeleton />}>.
Стрімінг часто призводить до різкого стрибка метрики CLS (Cumulative Layout Shift), якщо резервний блок (fallback) має висоту 50px, а завантажуваний компонент займає 400px. При підміні контенту нижчі елементи верстки різко зсуваються вниз, що карається пошуковими алгоритмами Google.
Рішення: Скелетони у fallback зобов'язані суворо резервувати фізичний простір (використовувати фіксовані класи min-h-[...], aspect-ratio або жорстку сітку), у точності повторюючи геометрію реального компонента.
Розбиття сторінки на кілька <Suspense> блоків не вирішує проблему, якщо один компонент усередині себе робить послідовні await (наприклад, await getProfile(); await getHistory();).
Рішення: Якщо запити незалежні, запускайте їх паралельно через Promise.all() або розділяйте на два незалежні компоненти з індивідуальними межами Suspense.
// ❌ Прихований waterfall всередині Suspense (1000ms сумарно)
async function UserActivity({ userId }: { userId: string }) {
const profile = await getProfile(userId); // 500ms
const history = await getHistory(profile.accountId); // ще 500ms
return <div>...</div>;
}
// ✅ ПАРАЛЕЛЬНИЙ ЗАПУСК (500ms сумарно):
async function UserActivityParallel({ userId, accountId }: { userId: string; accountId: string }) {
const [profile, history] = await Promise.all([getProfile(userId), getHistory(accountId)]);
return <div>...</div>;
}Хоча пошукові роботи (Googlebot) підтримують стрімінг і чекають розв'язання промісів до 5 секунд, деякі спеціалізовані краулери (парсери соцмереж, Telegram, Twitter cards) зчитують лише початковий HTML-чанк.
Якщо заголовок статті <h1>, опис або метадані обгорнути в <Suspense>, боти побачать як контент скелетон завантаження. Завжди залишайте критичний для SEO контент першого екрана в синхронній оболонці сторінки!