awesome-everything RU
↑ Back to the climb

Frontend Architecture

LCP, prefetch, and race conditions in interactive fetching

Crux How to diagnose LCP regressions from the network waterfall, what prefetching patterns buy and cost, and how to eliminate race conditions in search-as-you-type fetching.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 10 min

Your user types “react” into a search box. Two fetches fire — for “reac” then “react”. The “reac” response arrives 200ms after “react” because the server happened to be slower. The UI shows “reac” results while the input shows “react”. Classic race condition.

LCP and the critical fetch

Largest Contentful Paint measures when the page’s main content element becomes visible. For a product page, the LCP element is typically the hero image or the product title. Web Vitals thresholds:

  • Good: LCP under 2.5s
  • Excellent: LCP under 1.5s

The single biggest LCP improvement most apps can make: move the LCP element’s data fetching from client to server. A client-fetched title adds JS-download + mount + fetch to the LCP critical path — easily 2–3x the latency of an equivalent SSR page.

Supporting signals:

  • fetchpriority="high" on the LCP image tells the browser to prioritise its discovery early in parsing
  • <link rel="preload"> for LCP images whose URL is known at build time push them to the browser earlier
LCP optimization path
BEFORE — client fetch
HTML 50ms → JS bundle 600ms → mount 100ms → /api/product 200ms → LCP at ~950ms
AFTER — RSC server fetch
HTML with data 250ms → LCP at ~300ms (3x improvement)

Diagnosing a waterfall from DevTools

A real regression pattern seen in production: four sequential API calls, each starting only after the previous response:

GET /products/42        50ms  → 250ms
GET /app-bundle.js     260ms  → 850ms
GET /api/product/42    860ms  → 1300ms    (useEffect, starts after bundle)
GET /api/reviews       1310ms → 1700ms   (after product)
GET /api/user          1710ms → 2000ms   (after reviews)
LCP at 2050ms

Three antipatterns in one waterfall:

  1. Requests 3–5 are sequential when independent — no reason reviews and user need to wait for product response
  2. All three are useEffect-triggered — they wait for JS bundle download, parse, mount
  3. bundle.js is too large at ~590ms — code-split the LCP-critical path to under 50KB

After fix — RSC with server fetches in parallel, small interactive bundle:

GET /index.html (streaming)  50ms → 80ms  TTFB
streaming chunks             80ms → 300ms  LCP at ~320ms

Race conditions in interactive fetching

The search-as-you-type race: user types “rea” → fetch A fires. User types “react” → fetch B fires. Server is slower for fetch A. Fetch B resolves first (results for “react”), UI updates. Then fetch A resolves (results for “rea”), UI overwrites with wrong results.

Fix 1: AbortController — cancel the in-flight request when a new one starts:

let controller = new AbortController();

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

TanStack Query automatically passes an AbortSignal through the queryFn’s meta — queries for an old key are cancelled when the key changes.

Fix 2: Debounce — wait until the user stops typing before firing:

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

300ms delay means only one fetch fires per pause in typing.

Fix 3: queryKey versioning (TanStack Query built-in) — each response is tagged with the queryKey that produced it. The cache stores only the latest key’s data. Stale responses for old keys are discarded automatically.

Prefetch strategies

Smart fetching can start work before the user clicks:

Hover prefetch: when the cursor enters a link, start fetching the target. User typically clicks 100–300ms after hover begins — data is often done before they click.

<Link
  href={`/product/${id}`}
  onMouseEnter={() => queryClient.prefetchQuery({
    queryKey: ['product', id],
    queryFn: () => fetchProduct(id),
  })}
>
  View product
</Link>

Viewport prefetch: as a link scrolls into view, start prefetching. Useful for the next page in an infinite scroll list.

Next.js Link default: <Link href="..."> prefetches automatically in production when in viewport. Disable with prefetch={false} if bandwidth matters.

Tradeoff: prefetching uses bandwidth speculatively. On mobile with limited data plans, over-eager prefetching is expensive. Limit to next-likely targets; avoid prefetching large media.

Pagination: cursor vs offset

Offset pagination: ?page=2&limit=20. Simple but breaks under concurrent inserts: if a new item is inserted between page 1 and page 2 fetches, items shift and the user sees a duplicate or skipped item.

Cursor pagination: ?cursor=abc123. Stable across inserts because the cursor marks a position in the dataset, not a count. TanStack Query’s useInfiniteQuery handles cursor-based natively, with getNextPageParam extracting the next cursor from each response.

Use cursor pagination for any list under churn. Offset is acceptable only for rarely-mutated, static datasets.

LCP thresholds and fetch numbers
LCP 'good' threshold
under 2.5 s
LCP 'excellent' threshold
under 1.5 s
Hover-to-click delay (typical)
100–300 ms
Debounce for search inputs
200–400 ms
Promise.all speedup at N=5
~5x vs sequential
Quiz

A page has LCP of 4.2s. DevTools shows a staircase of 3 sequential /api fetches taking 1500ms total. What is the best first fix?

Quiz

A search input fires a fetch on every keystroke. The user types 'react' and gets 'reac' results displayed. What is the best fix?

Recall before you leave
  1. 01
    What are the LCP 'good' and 'excellent' thresholds?
  2. 02
    What is a search-box race condition and how does AbortController fix it?
  3. 03
    Why is cursor pagination preferable to offset for mutable data?
Recap

LCP measures when the page’s main content element becomes visible; Web Vitals targets under 2.5s (good) and 1.5s (excellent). The largest LCP win is moving the LCP element’s data fetch from client to server, cutting 2–3 sequential trips from the critical path. Diagnosing LCP regressions: look for a staircase in the Network panel — sequential requests with the start of each matching the end of the previous. Fix: Promise.all or RSC for independent server-side fetches; AbortController or debounce for client-side interactive flows. Prefetch on hover for near-instant navigations, but limit speculative prefetching on mobile to avoid wasting data plan bandwidth.

Connected lessons
appears again in178
Continue the climb ↑Senior internals: RSC payload, caching layers, and production failure modes
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.