Потоковая отдача: страница приходит частями
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Серверный компонент, который ждёт данные, задерживает не только себя: пока он считает, браузер не получил ни байта — ни шапки, ни меню, ни заголовка. Потоковая отдача разрывает эту связку: ответ уходит кусками, первый — сразу, а на месте медленного блока едет заглушка, которую дошлют, когда данные будут готовы. Где проходит эта линия отрыва, решаете вы — тем же <Suspense>, что и в браузере.
В прошлой статье данные брались await прямо в компоненте, и это оказалось короче и честнее, чем эффект с запросом. У приёма есть цена, и видна она не на одном запросе, а на настоящей странице товара.
Запросы здесь уходят разом, водопада нет — и всё равно страница целиком заперта самым медленным из них. Компонент — это функция: пока она не вернула разметку, возвращать нечего, а значит, и отправлять в браузер нечего. Шапка, меню и название готовы через несколько миллисекунд, но ждут почти секунду вместе с оценкой.
| Часть страницы | Готова через | Одним куском — увидят | Потоком — увидят |
|---|---|---|---|
| Шапка и меню | 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 мс | готовый блок «оценки» — тем же способом |
| в конце | закрывающие теги документа |
Заметьте порядок: блок, который в разметке стоит вторым, ушёл первым — просто потому, что раньше досчитался. Куски адресованы, поэтому порядок на проводе роли не играет. Соберём эту сборку сами.
Три состояния одной и той же страницы. Важное в первом: страница уже полная — в ней есть все места, просто два из них заняты заглушками. Читатель видит осмысленный экран, пока сервер ещё считает.
Документ пишется строго от начала к концу: дописать можно только в конец, вставить в середину — нельзя. Поэтому готовый блок приезжает в хвост документа спрятанным, а рядом едет крошечный встроенный скрипт, который переносит его на место заглушки. Это единственный способ попасть в середину уже отданной страницы.
Отсюда неочевидное следствие. Читатель с отключённым JavaScript получит весь текст — в документе он есть, — но останется с заглушками на экране, а содержимое блоков будет лежать в самом низу и невидимым. Значит, под границу уходит то, без чего страница остаётся осмысленной, а не то, ради чего на неё пришли.
Поток рвётся снаружи: прокси или CDN, который буферизует ответ целиком, сложит все куски вместе и отдаст их разом — выигрыш исчезнет, а код останется правильным. Это первое, что стоит проверять, если в проде поток «не работает».
Первая и самая частая ошибка выглядит абсолютно разумно: страница берёт данные, как раньше, а медленный блок на всякий случай обёрнут границей. В песочнице сервера нет, но приостановка работает точно так же, поэтому опыт ставится честно. Заглушка живёт отдельным компонентом и печатает, когда её показали. Предскажите: сколько строк будет в выводе и какие?
Одна строка, и та на девятисотой миллисекунде. Заглушка не печаталась вовсе — её никто не рисовал. Приостановка ищет ближайшую границу вверх по дереву, а обёртка стоит внутри того самого компонента, который приостановился: до неё дело просто не дошло. Всё, что ниже use, — включая <Suspense> — существует только после того, как ожидание закончилось.
Лечится это не обёрткой, а переносом ожидания. Медленное чтение уезжает в отдельный компонент, и граница оказывается над ним, а не под.
Теперь строк три: страница и её заглушка — на сотой миллисекунде, оценки — на девятисотой. Разметка не изменилась ни на символ, изменилось место, где написано слово ожидания.
На сервере правка выглядит так же — только слово ожидания другое, await вместо use.
Правило запоминается одной фразой: всё, что стоит выше границы, задерживает первый кусок. Поэтому в серверной странице await оставляют только для того, без чего оболочку не собрать, — а остальное уезжает в собственный компонент под <Suspense>.
Тот же опыт, поставленный на настоящем серверном рендере React 19, даёт разницу не в отрисовках, а в байтах. С await в теле страницы первый кусок ответа не уходит раньше самого медленного ожидания — здесь позже девятисотой миллисекунды, — и в нём сразу готовый блок, заглушки в ответе нет вообще. Когда ожидание переехало под границу, первый кусок ушёл на первых же миллисекундах, с заглушками внутри, а данные догнали его отдельными кусками — на 300-й и на 900-й.
Смысл тот же, что в песочнице: <Suspense> не ускоряет ни один запрос. Он только позволяет отдать остальное, не дожидаясь этого.
Расставлять границы вы умеете со статьи про use(). Здесь важно другое — что происходит, когда границ несколько, а данные приходят вразнобой. Три блока, у каждого своя граница, в разметке они стоят от самого медленного к самому быстрому. Предскажите обе части вывода: порядок прибытия и порядок на странице.
Пришли в обратном порядке — 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. Граница вокруг комментариев стоит, а заголовок всё равно появляется только на восьмисотой. Почините, не трогая ни задержки, ни разметку границы: заголовок должен оказаться на экране на сотой миллисекунде, а заглушка — показаться.
Ожидание нужно опустить под границу: компонент Comments сам читает промис, а страница о нём больше не знает.
Пропс text исчез вместе с проблемой: пока данные передаются сверху, ждать их приходится сверху. На сервере правка выглядит так же — await переезжает в тело Comments, и оболочка уходит в браузер, не дожидаясь комментариев.
Куда положить notFound()
Страница поста отдаёт оболочку сразу, а сам пост грузит под границей. Для несуществующего адреса читатель увидит экран «не найдено», но поисковик получит код 200 — и возьмёт пустую страницу в индекс. Объясните, почему так вышло, и переставьте код так, чтобы ответ был честным 404.
Проверка существования обязана произойти до первого куска, то есть выше границы — в самой странице. Под границей остаётся только то, от чего код ответа не зависит.
Да, страница теперь ждёт loadPost перед первым куском — и это правильная плата: без поста показывать всё равно нечего, а код ответа важнее сотни миллисекунд. Медленное и необязательное осталось под границей и по-прежнему дошлётся потоком.
Что уйдёт в первом куске
Ничего не запуская, разберите страницу по кускам: что уедет в браузер первой порцией, что придёт потом и в каком порядке, и что из этого увидит читатель с отключённым JavaScript.
В первом куске уезжает вся оболочка: <h1>Кофеварка X200</h1>, цена и обе заглушки — «Считаем оценки…» и «Ищем похожее…». Это обычная разметка, никакого JavaScript для её показа не нужно.
Дальше приходят два куска — в порядке готовности, а не в порядке разметки: если Related справится раньше, его кусок будет первым на проводе. Каждый кусок приезжает в конец документа скрытым, и встроенная строчка скрипта переставляет его на место своей заглушки.
Читатель без JavaScript получит весь текст — он в документе есть, — но заглушки у него останутся на месте, а содержимое обоих блоков окажется в самом низу страницы и невидимым. Отсюда правило: под границу уходит то, без чего страница остаётся осмысленной.