Frontend Performance: Series Hub and Quick Reference Guide

All blogs in the React & JS Performance series in reading order, plus quick reference on layout thrashing, will-change, passive listeners, and INP.

Ashish 12 min read

This is the hub post for the React & JavaScript Performance series on renderlog.in. If you’re trying to make a specific thing faster and don’t know where to start, the series index below will point you at the right post. If you want a quick explanation of a concept that comes up often but doesn’t need a full post to itself, the quick reference section after the index has you covered.

The index lists this hub first (start here), then 22 deep dives in the same reading order as the series navigation. Each post builds on the mental models from earlier ones, but every post also stands alone if you’re here for a specific topic.

Map of the frontend performance series: browser pipeline, React rendering, network, assets, memory, and advanced patterns: all connected as a learning path.

Complete Series Index

#PostOne-line description
1Frontend Performance: Series Hub and Quick Reference GuideStart here: full index, suggested order, and quick reference for common performance gotchas
2Browser Rendering Pipeline: How JS and CSS Become PixelsParse → Style → Layout → Paint → Composite, and what can skip each step
3The 16.6ms Frame Budget: Why Fast Loads Still Feel SlowHow the 60fps budget works and why Lighthouse scores miss runtime jank
4Core Web Vitals and Lighthouse: What the Scores MeanLCP, CLS, INP, TBT: what they measure and what they don’t
5React Re-rendering: When and Why Component Trees UpdateReconciliation, what triggers renders, and how to read the React Profiler
6React.memo, useMemo, useCallback: When They Help vs HurtMemoization APIs, their costs, and when not to reach for them
7React State Management: Centralized, Atomic, and ProxyuseState vs useReducer vs Zustand vs Context: when each fits
8React Concurrent Features: Urgent vs Deferred UI UpdatesstartTransition, deferred values, and keeping inputs responsive during heavy work
9Long Tasks and Main Thread Blocking: Breaking Up the WorkWhy long synchronous tasks cause jank and how to chunk or yield work
10Large Lists: Pagination, Infinite Scroll, and VirtualizationWhy huge DOM counts hurt and how pagination vs windowing trades off
11DOM Performance on Mobile: Lab vs Real Device RealityTouch latency, memory pressure, and why desktop lab numbers lie on phones
12Network Optimization in React SPAs: Caching and PrefetchingAvoiding data waterfalls, HTTP caching, and prefetch strategies
13Images, Fonts, and Third-Party Scripts: LCP and CLS KillersModern formats, font-display, fetchpriority, and the facade pattern for embeds
14Bundle Analysis, Tree Shaking, and Code SplittingDead-code elimination, dynamic import(), and what breaks tree-shaking
15Web Workers in React: Moving Heavy Work Off the Main ThreadMessage passing, transferable buffers, and patterns that keep the UI responsive
16OPFS: The Browser’s Built-in Filesystem for Large BlobsHigh-throughput local file I/O without blocking the main thread
17Why CSS Never Matches Figma: Browser vs Canvas PipelinesSubpixel, font metrics, and why design tools and browsers diverge
18JavaScript GC: Pauses, Allocation Rate, and Frontend JankGenerational GC, why allocation spikes cause hitches, and practical mitigation
19React Memory Leaks: Closures, Subscriptions, and Object GraphsRetained graphs, common leak patterns, and AbortController-style cleanup
20Lighthouse Score 100 and Still Crashes: OOM and Long SessionsWhy perfect lab scores miss production OOM and multi-hour tab failures
21Why React Error Boundaries Are Still Class ComponentsgetDerivedStateFromError and why error recovery is not a hook yet
22Meta StyleX: Moving CSS-in-JS From Runtime to Build TimeAtomic CSS at build time and eliminating runtime style injection
23How to Answer Frontend Performance Questions in InterviewsA repeatable frame: measure, hypothesize, instrument, fix, verify

Quick Reference

These are concepts that come up often in performance work, don’t need a full post to themselves, but are worth having a clear explanation of in one place.


Layout Thrashing

Layout thrashing happens when JavaScript alternates between reading layout properties (which force the browser to recalculate layout) and writing properties (which invalidate the layout calculation), doing this repeatedly in a loop.

// Thrashing: 100 reads, each forced by the previous write
for (const el of elements) {
  el.style.width = el.offsetWidth * 1.2 + "px"; // write, then read, then write...
}

// Batched: all reads first, then all writes
const widths = Array.from(elements).map(el => el.offsetWidth); // all reads
elements.forEach((el, i) => {
  el.style.width = widths[i] * 1.2 + "px"; // all writes
});

The browser is lazy about layout calculation: it doesn’t recalculate until it needs to. Reading offsetWidth, getBoundingClientRect(), scrollTop, or any other geometric property forces an immediate layout recalculation to return an accurate value. If you then write a property that affects layout, you invalidate the calculation. The next read forces another full recalculation. This is the “forced reflow” loop.

The FastDOM library provides a Promise-based scheduling abstraction that batches reads and writes automatically:

import fastdom from "fastdom";

elements.forEach(el => {
  fastdom.measure(() => {
    const width = el.offsetWidth;
    fastdom.mutate(() => {
      el.style.width = width * 1.2 + "px";
    });
  });
});

FastDOM queues all measure callbacks to run before mutate callbacks, batching the reads before the writes within a single animation frame.


Forced Reflow Properties

Reading any of these CSS/DOM properties triggers an immediate layout calculation. If layout is dirty (because you wrote to it), this is a forced synchronous reflow.

Property / MethodCategory
offsetWidth, offsetHeightBox model
offsetTop, offsetLeft, offsetParentBox model
clientWidth, clientHeightBox model
clientTop, clientLeftBox model
scrollWidth, scrollHeightScroll
scrollTop, scrollLeftScroll
getBoundingClientRect()Geometry
getComputedStyle()Computed style
innerWidth, innerHeightWindow
scrollX, scrollYWindow
document.elementFromPoint()Hit testing
focus()Interaction

A good rule: if you need to read any of these in a loop or immediately after a write, batch the reads before the writes. The Chrome DevTools Performance panel shows forced reflows as purple “Recalculate Style” and “Layout” blocks in the main thread flame chart if you see them in tight alternation, you have thrashing.


will-change Misuse

will-change hints to the browser that a specific CSS property is about to animate, allowing it to pre-promote the element to its own compositor layer (a separate GPU texture).

/* Correct: applied to elements that are about to animate */
.dropdown-menu {
  will-change: transform, opacity;
}

The trap is applying it everywhere as a “performance optimization”:

/* Wrong: promotes everything to GPU layers */
* {
  will-change: transform;
}

Every promoted layer consumes GPU memory. On mobile devices with limited VRAM, over-promoting causes the GPU to run out of memory for textures, which can actually hurt performance and cause rendering artifacts. Some developers have shipped sites that were slower after adding will-change globally than before.

Use will-change only on elements that will imminently animate, only for the specific properties being animated, and ideally apply it in JavaScript just before the animation starts rather than in static CSS:

// Apply right before animation
element.style.willChange = "transform";
element.animate([{ transform: "translateX(0)" }, { transform: "translateX(100px)" }], {
  duration: 300,
}).finished.then(() => {
  element.style.willChange = "auto"; // remove after animation completes
});

contain: strict: The Most Underused CSS Performance Property

contain tells the browser that an element and its subtree are independent from the rest of the document: layout, style, paint, and size changes inside it don’t affect anything outside it.

.widget {
  contain: strict; /* shorthand for layout + style + paint + size */
}

The strictest value, contain: strict, allows the browser to skip recalculating layout and painting for the rest of the page when something changes inside .widget. For component-based architectures where widgets are genuinely isolated, this is a significant optimization.

contain: content (layout + style + paint, without size) is usually safer because it doesn’t require the element to have a fixed size. Use strict when you know the element’s dimensions won’t change from the outside.

The browser support for contain is excellent (all modern browsers). It’s consistently underused because it’s not well-known. For complex dashboards with many independent widgets, adding contain: content to each widget can noticeably reduce paint and layout cost.


Paint Flashing in Chrome DevTools

Chrome DevTools has a visual debug tool that shows exactly which parts of the page are being repainted each frame. To enable it:

  1. Open DevTools → three-dot menu → More toolsRendering
  2. Enable Paint flashing

Painted areas are highlighted with a green overlay. Ideally, you want to see paint only on elements that actually changed animations, hover effects, focused inputs. If large areas of the page flash green on every frame or on every scroll event, you have unnecessary paint.

Common causes of excessive paint:

  • box-shadow and border-radius on elements that animate
  • background-color changes on large elements
  • CSS transitions on properties that aren’t compositor-only (width, height, top, left instead of transform)

For smooth animations, stick to transform and `opacity: these are handled entirely on the compositor thread and don’t trigger paint.


Passive Event Listeners

Touch and wheel event listeners can delay scrolling because the browser has to wait for the listener to complete before it knows whether preventDefault() was called (which would cancel the scroll). This causes scroll jank.

Passive listeners promise the browser that preventDefault() will never be called, allowing the browser to start scrolling immediately without waiting:

// Active (default): browser waits for handler before scrolling
window.addEventListener("scroll", handler);

// Passive: browser scrolls immediately, handler runs asynchronously
window.addEventListener("scroll", handler, { passive: true });

Chrome warns in DevTools when a non-passive touch or wheel listener is detected on a scroll container: “Added non-passive event listener to a scroll-blocking ‘touchstart’ event.” If you see this warning, add { passive: true } unless you genuinely need to call preventDefault().

React’s synthetic event system registers most event listeners passively by default in React 17+, but native addEventListener calls in useEffect still need the explicit flag.


Interaction to Next Paint (INP)

INP (Interaction to Next Paint) is a Core Web Vital for responsiveness. It measures how long it takes from a tap, click, or key press until the browser can paint the next frame that reflects the update. The score is driven by the slowest interactions in a session, not the average.

INP replaced FID (First Input Delay). FID only measured delay until the event handler started; INP includes handler work, style and layout, and paint. Long tasks, expensive React renders, layout thrashing, and synchronous main-thread work all inflate INP.

Mitigations line up with the rest of this series: break up long tasks, defer non-urgent UI with concurrent React, and keep critical interaction paths cheap. For how INP is collected and how it relates to LCP and CLS, see Core Web Vitals and Lighthouse.


requestIdleCallback: Scheduling Low-Priority Work

requestIdleCallback runs a callback when the browser’s main thread is idle: after it has finished handling user input, animations, and any other time-sensitive work. It’s the right tool for work that should happen eventually but never at the cost of frame rate.

// Run analytics batching and non-critical logging when idle
requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0 && pendingEvents.length > 0) {
    flushAnalyticsEvent(pendingEvents.shift());
  }
}, { timeout: 5000 }); // fall back to running after 5s even if never truly idle

The deadline object provides timeRemaining() (milliseconds until the next scheduled frame) and didTimeout (whether the timeout was hit). Check timeRemaining() in your loop to avoid overrunning your slice.

Good use cases: compressing analytics events into batched requests, prefetching low-priority data, indexing content for client-side search, logging, and cleanup tasks. Poor use cases: anything the user is waiting for, any animation, anything that must complete within a specific time frame.

Browser support is good in Chrome and Firefox. Safari added it in 16.4. For older environments, use a simple timeout-based polyfill: window.requestIdleCallback = window.requestIdleCallback || ((cb) => setTimeout(cb, 1)).


CSS Animations vs JavaScript Animations

ApproachRuns onCAN skip main thread?Best for
CSS transitionsCompositor (if transform/opacity)YesSimple A→B transitions
CSS animations (@keyframes)Compositor (if transform/opacity)YesLooping animations, keyframe sequences
Web Animations APICompositor (if transform/opacity)YesProgrammatic animations with JS control
requestAnimationFrame + style writesMain threadNoComplex physics, canvas, custom interpolation
GSAP, Framer MotionMain thread (JS-driven)No (unless using transforms only)Rich interactive animations

The rule: transform and opacity are compositor-only properties. CSS or Web Animations API animations on these two properties can run on the GPU compositor thread entirely, not blocking JavaScript or layout. Any other property width, height, background-color, top, `left) requires main-thread involvement.

If you’re animating something and it’s causing jank, the first question is: can this be expressed as a transform instead of a positional property? Moving an element by changing left from 0 to 100px triggers layout and paint every frame. Moving it with translateX(100px) on a promoted layer is virtually free.

FLIP technique: for cases where you must animate a layout change (like a list reordering), calculate the animation in advance, apply it as transform, and let the compositor run it. transform from the old position to transform: none at the new position: all on the compositor.


Start Here

If you’re new to frontend performance, use the series index at the top for the full path, then read these three in order (they match the series navigation after this hub):

  1. Browser Rendering Pipeline: how the page becomes pixels (parse → style → layout → paint → composite)
  2. The 16.6ms Frame Budget: the timing constraint everything else is measured against
  3. Core Web Vitals and Lighthouse: LCP, CLS, INP, and the vocabulary you’ll use when diagnosing

After those three, the rest of the series maps cleanly onto that mental model.

If you’re investigating a specific problem, use the series index at the top of this page to find the most relevant post directly.