Избегаем водопадов запросов
Водопад запросов — это независимые данные, загружаемые последовательно: незаметно, пока не появится реальная задержка. Лечится запуском независимых запросов параллельно и предзагрузкой вероятного следующего, без сериализации запросов, которые по-настоящему зависят друг от друга.
На твоей машине каждый запрос возвращается за 2мс, так что страница, выстреливающая четырьмя запросами один за другим, ощущается мгновенной. Ты выкатываешь её. Потом пользователь с гостиничного Wi-Fi открывает ту же страницу — и каждый круговой рейс стоит 300мс, а поскольку запросы идут по одному, страница, занимавшая локально 8мс, теперь грузится больше секунды. Данные никогда не зависели друг от друга; код просто этого не сказал.
Это водопад запросов: независимые данные, загружаемые последовательно, потому что каждый await или каждый вложенный компонент блокирует следующий за ним. Это самый частый баг производительности в data-heavy React, потому что он невидим ровно там, где ты тестируешь, — при нулевой задержке — и появляется только под той задержкой, что реально есть у твоих пользователей. Этот урок — про то, как видеть водопады и сплющивать их, и про два способа, которыми сплющивание идёт не так.
После этого урока ты можешь распознать водопад запросов и в клиентском коде (последовательные await, цепочки fetch-в-эффекте, вложенные data-компоненты под Suspense), и в деревьях RSC; сплющить независимые запросы, подняв их и запустив параллельно через Promise.all или параллельные загрузки RSC; предзагрузить вероятное следующее, чтобы спрятать задержку до того, как её заметят; и — senior-половина — распознать два режима отказа: параллелизацию запросов, которые по-настоящему зависят друг от друга (что ломает корректность), и сверхпредзагрузку всего подряд (что тратит трафик и работу сервера).
Водопад — это последовательная задержка на данных, у которых нет последовательной зависимости. Признак — два await подряд, где второй аргумент не использует результат первого. Каждый await паркует функцию, пока его промис не разрешится, так что запросы идут нос-к-хвосту, и общее время становится суммой круговых рейсов вместо максимума.
// водопад: user и posts независимы, но идут последовательно
async function load(userId: string) {
const user = await fetchUser(userId); // 300ms
const posts = await fetchPosts(userId); // 300ms — стартует только после разрешения user
return { user, posts }; // итого ≈ 600ms
}fetchPosts никогда не читает user. Нет причины ему ждать. Лечение — запустить оба до того, как ждать любой из них, чтобы круговые рейсы перекрылись.
Лечи независимые запросы, запуская их вместе, а потом ожидая группу — Promise.all. Сначала запусти промисы (пока не await), затем дождись их как набора. Теперь два круговых рейса перекрываются, и общее время схлопывается из суммы в максимум.
// параллельно: оба запроса в полёте до того, как любой из них ожидается
async function load(userId: string) {
const userP = fetchUser(userId); // запущен, не ожидается
const postsP = fetchPosts(userId); // запущен, не ожидается
const [user, posts] = await Promise.all([userP, postsP]); // итого ≈ 300ms
return { user, posts };
}Структурный сдвиг мал, но точен: создавай промисы рано, ожидай их поздно. Если один отклоняется, Promise.all отклоняется сразу — используй Promise.allSettled, когда частичный результат всё равно должен отрендериться. Это один и тот же приём — что в загрузчике маршрута, что в серверном action, что в обычной async-функции.
В RSC водопад прячется в дереве компонентов: дочерний, который делает await, не может стартовать, пока родитель не отрендерился. Server Components могут быть async и await-ить данные напрямую, что чисто, — но если родитель ждёт, рендерится и только затем рендерит дочерний, который тоже ждёт, запрос дочернего стартует поздно. Подними независимые загрузки в общего предка и запусти их параллельно, либо рендери соседние async-компоненты, чтобы React мог обработать их одновременно.
// водопад: Profile ждёт, ЗАТЕМ рендерит Feed, который ждёт
async function Profile({ id }: { id: string }) {
const user = await getUser(id); // круговой рейс 1
return <><Header user={user} /><Feed id={id} /></>; // загрузка Feed стартует только сейчас
}
// сплющено: запусти оба заранее, передай промисы вниз, дай Suspense стримить каждый
function Profile({ id }: { id: string }) {
const userP = getUser(id); // оба в полёте немедленно
const feedP = getFeed(id);
return (
<>
<Suspense fallback={<HeaderSkeleton />}><Header userP={userP} /></Suspense>
<Suspense fallback={<FeedSkeleton />}><Feed feedP={feedP} /></Suspense>
</>
);
}Передача промиса вниз (и чтение его через use внутри каждого дочернего) даёт обоим запросам стартовать в один момент, пока каждая секция стримится независимо за своей границей Suspense.
Предзагружай вероятное следующее, чтобы круговой рейс уже завершился, когда пользователь его запросит. Если ты можешь предсказать следующий вид — строку, на которую пользователь наводит курсор, маршрут под курсором, вкладку, которую он откроет, — запусти его запрос рано, а не жди клика. С кэшем серверного состояния (TanStack Query, предзагрузка маршрута RSC) загруженные данные тёплые к моменту навигации, превращая видимый спиннер в мгновенный рендер.
// прогреваем кэш по наведению; клик затем читает из кэша, без водопада
function UserRow({ id }: { id: string }) {
const qc = useQueryClient();
return (
<a
href={`/users/${id}`}
onMouseEnter={() => qc.prefetchQuery({ queryKey: ["user", id], queryFn: () => fetchUser(id) })}
>
View
</a>
);
}Предзагрузка — это задержка, спрятанная заранее, а не сокращённая. Но она тратит трафик и работу сервера на догадку — поэтому предзагружай только то, что вероятно следующее (строку под курсором, соседний маршрут), а не всё на странице. Сверхпредзагрузка — это свой собственный баг производительности, разобранный ниже.
Панель дашборда, из водопада в параллель. Панель показывает текущего пользователя, его команду и недавнюю активность команды. Первая версия читается чисто сверху вниз — и это три последовательных круговых рейса.
// ДО: три await, каждый независим от предыдущего, ~900мс при RTT 300мс
async function DashboardData({ userId }: { userId: string }) {
const user = await fetchUser(userId); // 300ms
const team = await fetchTeam(userId); // +300ms (не использует `user`)
const activity = await fetchActivity(userId); // +300ms (не использует ни `user`, ни `team`)
return { user, team, activity };
}Ни один из трёх запросов не потребляет результат другого — все они ключуются по userId, который у нас уже есть. Так что им незачем идти по очереди. Запусти все три, затем дождись группу:
// ПОСЛЕ: все три в полёте сразу, ~300мс итого
async function DashboardData({ userId }: { userId: string }) {
const [user, team, activity] = await Promise.all([
fetchUser(userId),
fetchTeam(userId),
fetchActivity(userId),
]);
return { user, team, activity };
}Три круговых рейса стали временем по часам в один круговой рейс — ускорение в 3× при нулевом изменении того, что загружается или рендерится. Теперь допустим, активности по-настоящему нужен id команды (fetchActivity(team.id)): эта одна зависимость реальна, так что она обязана ждать team. Senior-приём — параллелить независимое и сериализовать только настоящую зависимость: const team = await fetchTeam(userId) параллельно с user, затем fetchActivity(team.id) после. Ты не сплющиваешь вслепую — ты сплющиваешь ровно те рёбра, что не являются настоящими зависимостями.
▸Частая ошибка
Опасная сверхкоррекция — параллелить запросы, которые по-настоящему зависят друг от друга. Если fetchActivity нужен id, который возвращает только fetchTeam, ты не можешь выстрелить ими вместе — у Promise.all([fetchTeam(userId), fetchActivity(???)]) нет id для передачи. Форсировать параллелизм здесь значит либо угадывать id, либо загружать не то, либо загружать слишком много и фильтровать на клиенте. Настоящая зависимость данных — это ограничение корректности, а не баг задержки. Навык — отличать одно от другого: независимые запросы (один вход, без передачи результата) параллелятся; зависимые запросы (вход одного — выход другого) остаются последовательными. Сплющивание настоящей зависимости не делает страницу быстрее — оно делает её неправильной.
▸Граничные случаи
Другой режим отказа — предзагрузка всего подряд «чтобы быстро». Предзагрузка тратит реальный трафик и реальную работу сервера на предсказание, а большинство предсказаний неверны: предзагрузка каждой строки списка из 100 элементов на монтировании или каждого маршрута, на который ссылается страница, может выстрелить десятками запросов, которые пользователю никогда не понадобились, — замедляя те запросы, что нужны, борьбой за пул соединений и добавляя нагрузку на бэкенд впустую. Предзагрузка — это ставка; ставь только на высоковероятные следующие шаги (наведённая ссылка, самая вероятная вкладка, следующая страница пагинированного списка, который пользователь прокручивает). На лимитированных или медленных соединениях ставь предзагрузку за navigator.connection?.saveData или пропускай её вовсе. «Загружай параллельно» и «предзагружай агрессивно» — не одна и та же инструкция: первая бесплатна, у второй есть цена, которую ты обязан оправдать.
Загрузчик делает `const order = await fetchOrder(orderId)`, затем `const customer = await fetchCustomer(order.customerId)`. Коллега хочет обернуть оба в Promise.all, чтобы «убить водопад». Каков верный шаг?
Водопад запросов — это независимые данные, загружаемые последовательно: await подряд, где поздний запрос не использует ранний результат, или дочерний RSC, чья загрузка не может стартовать, пока родитель не отрендерился. Он невидим под нулевой задержкой, с которой ты тестируешь, и беспощаден под задержкой, что есть у твоих пользователей, где общее время становится суммой круговых рейсов вместо максимума. Сплющивай его, поднимая независимые запросы и запуская их одновременно — Promise.all (или allSettled для частичных результатов) в клиентском коде и загрузчиках, параллельные async-соседи с промисами, переданными вниз к границам Suspense в RSC. Прячь остаточную задержку предзагрузкой вероятного следующего, чтобы круговой рейс завершился до того, как пользователь спросит. Затем стереги два режима отказа: никогда не параллель настоящую зависимость (вход одного запроса — выход другого, такое обязано оставаться последовательным, иначе страница неправильна) и никогда не сверхпредзагружай (догадка, стоящая трафика и работы сервера, оправданная только для высоковероятных следующих шагов). Параллель то, что независимо; сериализуй то, что по-настоящему зависит; предзагружай то, что вероятно, — и ничего больше.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.