open atlas
↑ К треку
Архитектура фронтенда FE · 02 · 05

LCP, prefetch и race conditions в интерактивном fetching

Как диагностировать LCP регрессии по network waterfall, что даёт prefetch и чего стоит, и как устранить race conditions в поиске по мере ввода.

FE Middle ◷ 10 min
Уровень
ОсновыJuniorMiddleSenior

Пользователь вводит “react” в поле поиска. Запускаются два fetch’а — для “reac” и “react”. Ответ на “reac” приходит на 200ms позже “react” из-за медленного сервера. UI показывает результаты “reac” пока в поле введено “react”. Классический race condition.

LCP и критический fetch

К концу урока ты сможешь прочитать Network waterfall, объяснить LCP-регрессию одной фразой и назвать конкретный фикс — ещё до того, как откроешь конфиг.

Largest Contentful Paint измеряет когда основной контент-элемент страницы становится виден. Для страницы товара LCP-элемент обычно — hero image или заголовок товара. Пороги Web Vitals:

  • Хорошо: LCP менее 2.5s
  • Отлично: LCP менее 1.5s

Самое большое улучшение LCP, которое могут сделать большинство приложений: перенести data fetching LCP-элемента с клиента на сервер. Client-fetched заголовок добавляет загрузку JS + монтирование + fetch к LCP critical path — легко в 2–3 раза дольше, чем эквивалентная SSR страница.

Дополнительные инструменты:

  • fetchpriority="high" на LCP изображении сигнализирует браузеру приоритизировать его обнаружение при парсинге
  • <link rel="preload"> для LCP изображений, URL которых известен при сборке, раньше достигают браузера
Путь оптимизации LCP
ДО — client fetch
HTML 50ms → JS bundle 600ms → mount 100ms → /api/product 200ms → LCP ~950ms
ПОСЛЕ — RSC server fetch
HTML с данными 250ms → LCP ~300ms (улучшение в 3x)

Диагностика waterfall’а из DevTools

Реальный паттерн регрессии в production: четыре последовательных API вызова, каждый начинается только после предыдущего ответа:

GET /products/42        50ms  → 250ms
GET /app-bundle.js     260ms  → 850ms
GET /api/product/42    860ms  → 1300ms    (useEffect, после bundle)
GET /api/reviews       1310ms → 1700ms   (после product)
GET /api/user          1710ms → 2000ms   (после reviews)
LCP на 2050ms

Три антипаттерна в одном waterfall’е:

  1. Запросы 3–5 последовательны, хотя независимы — reviews и user не обязаны ждать ответ на product
  2. Все три запускаются useEffect’ом — ждут загрузки JS bundle, парсинга, монтирования
  3. bundle.js слишком большой (~590ms) — code-split критический LCP-путь до 50KB

Все три усиливают друг друга: большой bundle задерживает срабатывание useEffect, который задерживает последовательные fetch’и. Исправь любой один — срежешь 300–600ms; исправь все три — LCP падает с 2s ниже 400ms.

После фикса — RSC с параллельными серверными fetch’ами, небольшой интерактивный bundle:

GET /index.html (streaming)  50ms → 80ms  TTFB
streaming чанки              80ms → 300ms  LCP ~320ms

Race conditions в интерактивном fetching

Race condition поиска по вводу: пользователь вводит “rea” → fetch A запускается. Вводит “react” → fetch B. Сервер медленнее для fetch A. Fetch B разрешается первым (результаты для “react”), UI обновляется. Потом приходит fetch A (результаты для “rea”), UI перезаписывается неверными результатами.

Фикс 1: AbortController — отменить in-flight запрос при новом нажатии клавиши:

let controller = new AbortController();

function search(query: string) {
  controller.abort();
  controller = new AbortController();
  return fetch(`/api/search?q=${query}`, { signal: controller.signal });
}

TanStack Query автоматически передаёт AbortSignal через meta queryFn’а — запросы для старого ключа отменяются при изменении ключа.

Фикс 2: Debounce — ждать пока пользователь перестанет печатать перед запуском:

const debouncedQuery = useDebounce(inputValue, 300);
const { data } = useQuery({
  queryKey: ['search', debouncedQuery],
  queryFn: () => searchProducts(debouncedQuery),
  enabled: debouncedQuery.length > 1,
});

Задержка 300ms означает один fetch на паузу в наборе.

Фикс 3: queryKey versioning (встроено в TanStack Query) — каждый ответ помечен queryKey который его произвёл. Кэш хранит только данные последнего ключа. Устаревшие ответы для старых ключей отбрасываются автоматически.

Стратегии prefetch

Зачем ждать клика, если можно предсказать, куда идёт пользователь? Prefetch — это ответ. Но только если применять его избирательно.

Умный fetching может начать работу до того, как пользователь кликнет:

Hover prefetch: когда курсор входит в ссылку, начать fetch’ить цель. Пользователь обычно кликает через 100–300ms после hover — данные часто готовы до клика.

<Link
  href={`/product/${id}`}
  onMouseEnter={() => queryClient.prefetchQuery({
    queryKey: ['product', id],
    queryFn: () => fetchProduct(id),
  })}
>
  Смотреть товар
</Link>

Viewport prefetch: когда ссылка прокручивается в область видимости, начать prefetch. Полезно для следующей страницы в infinite scroll списке.

Next.js Link по умолчанию: <Link href="..."> делает prefetch автоматически в production при нахождении в viewport. Отключить через prefetch={false} если важен трафик.

Компромисс: prefetch использует bandwidth спекулятивно. На мобильном с лимитированным трафиком избыточный prefetch дорог. Ограничить следующими вероятными целями; избегать prefetch крупных медиа.

Пагинация: cursor vs offset

Offset пагинация: ?page=2&limit=20. Просто, но ломается при конкурентных вставках: если новый элемент вставлен между fetch’ами страницы 1 и 2, элементы смещаются и пользователь видит дубликат или пропуск.

Cursor пагинация: ?cursor=abc123. Стабильна при вставках потому что cursor маркирует позицию в наборе данных, а не счётчик. TanStack Query useInfiniteQuery нативно обрабатывает cursor-based с getNextPageParam извлекающим следующий cursor из каждого ответа.

Использовать cursor пагинацию для любого списка под нагрузкой. Offset приемлем только для статических или редко изменяемых данных.

LCP пороги и числа fetch'а
LCP порог 'хорошо'
менее 2.5 s
LCP порог 'отлично'
менее 1.5 s
Задержка hover-to-click (типично)
100–300 ms
Debounce для поисковых полей
200–400 ms
Ускорение Promise.all при N=5
~5x vs последовательный
Викторина

Страница имеет LCP 4.2s. DevTools показывает лесенку из 3 последовательных /api fetch'ей общим временем 1500ms. Какой лучший первый фикс?

Викторина

Поисковое поле запускает fetch при каждом нажатии клавиши. Пользователь вводит 'react' и видит результаты 'reac'. Лучший фикс?

Вспомните перед уходом
  1. 01
    Каковы LCP пороги 'хорошо' и 'отлично'?
  2. 02
    Что такое race condition в поисковом поле и как AbortController его исправляет?
  3. 03
    Почему cursor пагинация предпочтительнее offset для изменяемых данных?
Итог

LCP (Largest Contentful Paint — время до появления главного контент-элемента) измеряет когда основной контент-элемент страницы становится виден; Web Vitals цели — менее 2.5s (хорошо) и 1.5s (отлично). Самое большое улучшение LCP — перенести данные LCP-элемента с клиента на сервер, убирая 2–3 последовательных trip’а из critical path. Диагностировать LCP регрессии: ищи лесенку в Network panel — последовательные запросы где начало каждого совпадает с концом предыдущего. Фикс: Promise.all или RSC для независимых серверных fetch’ей; AbortController или debounce для клиентских интерактивных потоков. Prefetch при hover для почти мгновенных навигаций, но ограничить спекулятивный prefetch на мобильном для экономии трафика. Теперь, когда тимейт сообщит о «неправильных результатах поиска при быстрой печати», ты первым делом потянешься к AbortController — а не к спиннеру или сбросу кэша.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки
встречается в204

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.