Потоковая отдача: страница приходит частями

серверные≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Серверный компонент, который ждёт данные, задерживает не только себя: пока он считает, браузер не получил ни байта — ни шапки, ни меню, ни заголовка. Потоковая отдача разрывает эту связку: ответ уходит кусками, первый — сразу, а на месте медленного блока едет заглушка, которую дошлют, когда данные будут готовы. Где проходит эта линия отрыва, решаете вы — тем же <Suspense>, что и в браузере.

Разделы темы:·

В прошлой статье данные брались await прямо в компоненте, и это оказалось короче и честнее, чем эффект с запросом. У приёма есть цена, и видна она не на одном запросе, а на настоящей странице товара.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Запросы здесь уходят разом, водопада нет — и всё равно страница целиком заперта самым медленным из них. Компонент — это функция: пока она не вернула разметку, возвращать нечего, а значит, и отправлять в браузер нечего. Шапка, меню и название готовы через несколько миллисекунд, но ждут почти секунду вместе с оценкой.

Часть страницыГотова черезОдним куском — увидятПотоком — увидят
Шапка и меню5 мс900 мс5 мс
Название и цена40 мс900 мс40 мс
Оценка по отзывам900 мс900 мс900 мс

Правый столбец — это и есть потоковая отдача. Ничего нового в React для неё не появилось: линия отрыва — тот же <Suspense>, что вы ставили в браузере. Меняется только то, что происходит на границе: вместо «показать заглушку в DOM» сервер отдаёт заглушку в первом куске ответа, а готовый блок дошлёт тем же соединением.

Файл loading.tsx из главы про Next — ровно такая граница, поставленная фреймворком вокруг всего сегмента: либо весь сегмент ждёт, либо весь сегмент показан. Дальше речь о нарезке помельче — внутри одной страницы.

Числа в таблице условные, а соотношение типичное: шапка и текст готовы почти мгновенно, а секунду отнимает один-два блока, ради которых читатель обычно и не пришёл.

HTTP-ответ — не файл, а поток байтов: сервер вправе начать его отдавать, ещё не зная, чем закончит. На этом всё и держится. React отправляет первую порцию, как только готова оболочка — всё дерево за вычетом приостановленных мест. На месте каждой границы в разметке стоит её fallback и невидимая метка с адресом.

Вот что уходит в браузер у страницы товара, под границами которой стоят оценки и «похожие товары». Это не пересказ документации: так выглядит поток настоящего серверного рендера React 19, если записать его кусками с отметками времени.

КогдаЧто уходит в браузер
сразу<h1>Кофеварка X200</h1>, цена и обе заглушки — обычной разметкой, плюс метка места для каждой границы
300 мсготовый блок «похожие товары» — скрытым, в конце документа, и рядом строчка скрипта, которая ставит его на место
900 мсготовый блок «оценки» — тем же способом
в концезакрывающие теги документа

Заметьте порядок: блок, который в разметке стоит вторым, ушёл первым — просто потому, что раньше досчитался. Куски адресованы, поэтому порядок на проводе роли не играет. Соберём эту сборку сами.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три состояния одной и той же страницы. Важное в первом: страница уже полная — в ней есть все места, просто два из них заняты заглушками. Читатель видит осмысленный экран, пока сервер ещё считает.

Документ пишется строго от начала к концу: дописать можно только в конец, вставить в середину — нельзя. Поэтому готовый блок приезжает в хвост документа спрятанным, а рядом едет крошечный встроенный скрипт, который переносит его на место заглушки. Это единственный способ попасть в середину уже отданной страницы.

Отсюда неочевидное следствие. Читатель с отключённым JavaScript получит весь текст — в документе он есть, — но останется с заглушками на экране, а содержимое блоков будет лежать в самом низу и невидимым. Значит, под границу уходит то, без чего страница остаётся осмысленной, а не то, ради чего на неё пришли.

Поток рвётся снаружи: прокси или CDN, который буферизует ответ целиком, сложит все куски вместе и отдаст их разом — выигрыш исчезнет, а код останется правильным. Это первое, что стоит проверять, если в проде поток «не работает».

Первая и самая частая ошибка выглядит абсолютно разумно: страница берёт данные, как раньше, а медленный блок на всякий случай обёрнут границей. В песочнице сервера нет, но приостановка работает точно так же, поэтому опыт ставится честно. Заглушка живёт отдельным компонентом и печатает, когда её показали. Предскажите: сколько строк будет в выводе и какие?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна строка, и та на девятисотой миллисекунде. Заглушка не печаталась вовсе — её никто не рисовал. Приостановка ищет ближайшую границу вверх по дереву, а обёртка стоит внутри того самого компонента, который приостановился: до неё дело просто не дошло. Всё, что ниже use, — включая <Suspense> — существует только после того, как ожидание закончилось.

Лечится это не обёрткой, а переносом ожидания. Медленное чтение уезжает в отдельный компонент, и граница оказывается над ним, а не под.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Теперь строк три: страница и её заглушка — на сотой миллисекунде, оценки — на девятисотой. Разметка не изменилась ни на символ, изменилось место, где написано слово ожидания.

На сервере правка выглядит так же — только слово ожидания другое, await вместо use.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Правило запоминается одной фразой: всё, что стоит выше границы, задерживает первый кусок. Поэтому в серверной странице await оставляют только для того, без чего оболочку не собрать, — а остальное уезжает в собственный компонент под <Suspense>.

Тот же опыт, поставленный на настоящем серверном рендере React 19, даёт разницу не в отрисовках, а в байтах. С await в теле страницы первый кусок ответа не уходит раньше самого медленного ожидания — здесь позже девятисотой миллисекунды, — и в нём сразу готовый блок, заглушки в ответе нет вообще. Когда ожидание переехало под границу, первый кусок ушёл на первых же миллисекундах, с заглушками внутри, а данные догнали его отдельными кусками — на 300-й и на 900-й.

Смысл тот же, что в песочнице: <Suspense> не ускоряет ни один запрос. Он только позволяет отдать остальное, не дожидаясь этого.

Расставлять границы вы умеете со статьи про use(). Здесь важно другое — что происходит, когда границ несколько, а данные приходят вразнобой. Три блока, у каждого своя граница, в разметке они стоят от самого медленного к самому быстрому. Предскажите обе части вывода: порядок прибытия и порядок на странице.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пришли в обратном порядке — 300, 600, 900, — а на странице встали ровно так, как записаны в разметке. Место каждого блока всё это время держала его заглушка, и приехавший кусок занял именно его, а не пристроился в конец. Поэтому потоковая страница не прыгает: заполняются дырки, а не добавляются строки.

Это и есть ответ на вопрос, зачем в каждом куске адрес. Сервер не выстраивает блоки в очередь и не ждёт «своего» порядка: досчитал — отправил, а расставит их браузер.

Раз порядок значения не имеет, остаётся один настоящий вопрос — что именно резать.

Часть страницыПод границу?
Шапка, меню, хлебные крошкинет — они готовы мгновенно, границе нечего ждать
Заголовок, цена, картинка товаранет — ради них читатель и пришёл, они обязаны быть в оболочке
Оценки, отзывы, «похожие товары»да — их не жалко показать на секунду позже
Виджет из чужого APIда, обязательно — чужая медлительность не должна держать вашу страницу

Заглушка должна занимать примерно столько же места, сколько займёт готовый блок: иначе в момент подстановки страница дёрнется и читатель промахнётся мимо кнопки. Скелет по размеру блока лучше строчки «Загрузка…».

У потока есть свойство, о котором вспоминают поздно и больно. Код ответа и заголовки — это самые первые байты, они уходят раньше разметки. Как только первый кусок покинул сервер, менять в них нечего: они уже в сети.

ЧтоДо первого кускаПосле первого куска
Код ответа: 200, 404, 500можно назначить любойуже отправлен
Заголовки, включая Set-Cookieможно поставитьуже отправлены
Переадресация ответом сервераможнонельзя — только скриптом в браузере
Разметка любого отложенного куска—можно: ради этого поток и затевался

Отсюда практическое правило, которое стоит помнить целиком: всё, от чего зависит код ответа, решается выше границы. Проверка «есть ли такой товар» — до <Suspense>, в самой странице; «сколько у него отзывов» — под границей, сколько угодно медленно.

С ошибками то же самое, только мягче. Упавший компонент под границей никуда поток не роняет: error.tsx из главы про App Router поймает ошибку, и его разметка дошлётся обычным куском, встанет на место заглушки. Читатель увидит аккуратный блок «не удалось загрузить отзывы» вместо оборванной страницы — а код ответа останется тем, что ушёл первым куском.

Можно — просто вопрос «а есть ли страница» решается до потока, а не в нём. Схема живучая: страница дожидается того минимума, без которого показывать нечего (поста, товара, профиля), и уже точно знает свой код ответа; всё остальное — комментарии, рекомендации, счётчики — уезжает под границы и догоняет потоком.

Плата за это — те самые сто миллисекунд, которые страница ждёт свой минимум. Именно поэтому правило звучит не «уберите все await», а «оставьте наверху только необходимые».

Отладка потока начинается с curl -N — без буферизации: если куски печатаются по одному, с паузами, сервер работает правильно и разбираться нужно с прокси. Браузерная вкладка «Сеть» показывает уже собранный ответ и в этом вопросе не помощник.

  • Потоковая отдача разрывает связку «страница готова целиком — страница ушла в браузер»: оболочка отправляется сразу, медленные блоки догоняют кусками по тому же соединению.
  • Линию отрыва ставит <Suspense>: в первом куске на месте границы едет её fallback, а готовый блок приезжает позже — скрытым в конце документа, и встроенный скрипт переставляет его на место.
  • Всё, что стоит выше границы, задерживает первый кусок: await в теле страницы держит и шапку, и заголовок, а обёртка внутри такого компонента не показывается вовсе.
  • Куски приходят в порядке готовности, а на странице встают в порядке разметки — место всё это время держит заглушка, поэтому страница заполняется, а не перестраивается.
  • Код ответа и заголовки уходят первыми байтами: notFound(), переадресация и Set-Cookie должны решаться выше границы, до первого куска.
  • Под границу отправляют то, без чего страница остаётся осмысленной, — отзывы, рекомендации, чужие виджеты; заголовок, цену и текст оставляют в оболочке.

1/4Серверная страница берёт оценку товара через await в своём теле, а внутри разметки стоит <Suspense> вокруг блока с этой оценкой. Когда в браузер уйдёт первый кусок ответа?

2/4Три блока стоят под своими границами в порядке «медленный, средний, быстрый» и готовятся за 900, 600 и 300 миллисекунд. Что произойдёт?

3/4Оболочка уже ушла в браузер, и тут компонент под границей выяснил, что товара с таким адресом нет, и позвал notFound().

4/4Почему готовый блок приезжает скрытым в конце документа, а не прямо на своё место в середине страницы?

Оболочка застряла

Статья готова через 100 миллисекунд, комментарии — через 800. Граница вокруг комментариев стоит, а заголовок всё равно появляется только на восьмисотой. Почините, не трогая ни задержки, ни разметку границы: заголовок должен оказаться на экране на сотой миллисекунде, а заглушка — показаться.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ожидание нужно опустить под границу: компонент Comments сам читает промис, а страница о нём больше не знает.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Пропс text исчез вместе с проблемой: пока данные передаются сверху, ждать их приходится сверху. На сервере правка выглядит так же — await переезжает в тело Comments, и оболочка уходит в браузер, не дожидаясь комментариев.

Куда положить notFound()

Страница поста отдаёт оболочку сразу, а сам пост грузит под границей. Для несуществующего адреса читатель увидит экран «не найдено», но поисковик получит код 200 — и возьмёт пустую страницу в индекс. Объясните, почему так вышло, и переставьте код так, чтобы ответ был честным 404.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверка существования обязана произойти до первого куска, то есть выше границы — в самой странице. Под границей остаётся только то, от чего код ответа не зависит.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Да, страница теперь ждёт loadPost перед первым куском — и это правильная плата: без поста показывать всё равно нечего, а код ответа важнее сотни миллисекунд. Медленное и необязательное осталось под границей и по-прежнему дошлётся потоком.

Что уйдёт в первом куске

Ничего не запуская, разберите страницу по кускам: что уедет в браузер первой порцией, что придёт потом и в каком порядке, и что из этого увидит читатель с отключённым JavaScript.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В первом куске уезжает вся оболочка: <h1>Кофеварка X200</h1>, цена и обе заглушки — «Считаем оценки…» и «Ищем похожее…». Это обычная разметка, никакого JavaScript для её показа не нужно.

Дальше приходят два куска — в порядке готовности, а не в порядке разметки: если Related справится раньше, его кусок будет первым на проводе. Каждый кусок приезжает в конец документа скрытым, и встроенная строчка скрипта переставляет его на место своей заглушки.

Читатель без JavaScript получит весь текст — он в документе есть, — но заглушки у него останутся на месте, а содержимое обоих блоков окажется в самом низу страницы и невидимым. Отсюда правило: под границу уходит то, без чего страница остаётся осмысленной.