В традиционной архитектуре буферизированного 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 16 для предотвращения случайного выполнения динамического кода на этапе статического пререндеринга сборщика используется утилита 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, VK, Twitter cards) считывают только начальный HTML-чанк.
Если заголовок статьи <h1>, описание или метаданные обернуть в <Suspense>, боты увидят в качестве контента скелетон загрузки. Всегда оставляйте критический для SEO контент первого экрана в синхронной оболочке страницы!