DEV Community

Ashish Kumar
Ashish Kumar

Posted on • Originally published at renderlog.in

Frontend Performance: Series Hub and Quick Reference Guide

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

# Post One-line description
1 Frontend Performance: Series Hub and Quick Reference Guide Start here: full index, suggested order, and quick reference for common performance gotchas
2 Browser Rendering Pipeline: How JS and CSS Become Pixels Parse → Style → Layout → Paint → Composite, and what can skip each step
3 The 16.6ms Frame Budget: Why Fast Loads Still Feel Slow How the 60fps budget works and why Lighthouse scores miss runtime jank
4 Core Web Vitals and Lighthouse: What the Scores Mean LCP, CLS, INP, TBT: what they measure and what they don't
5 React Re-rendering: When and Why Component Trees Update Reconciliation, what triggers renders, and how to read the React Profiler
6 React.memo, useMemo, useCallback: When They Help vs Hurt Memoization APIs, their costs, and when not to reach for them
7 React State Management: Centralized, Atomic, and Proxy useState vs useReducer vs Zustand vs Context: when each fits
8 React Concurrent Features: Urgent vs Deferred UI Updates startTransition, deferred values, and keeping inputs responsive during heavy work
9 Long Tasks and Main Thread Blocking: Breaking Up the Work Why long synchronous tasks cause jank and how to chunk or yield work
10 Large Lists: Pagination, Infinite Scroll, and Virtualization Why huge DOM counts hurt and how pagination vs windowing trades off
11 DOM Performance on Mobile: Lab vs Real Device Reality Touch latency, memory pressure, and why desktop lab numbers lie on phones
12 Network Optimization in React SPAs: Caching and Prefetching Avoiding data waterfalls, HTTP caching, and prefetch strategies
13 Images, Fonts, and Third-Party Scripts: LCP and CLS Killers Modern formats, font-display, fetchpriority, and the facade pattern for embeds
14 Bundle Analysis, Tree Shaking, and Code Splitting Dead-code elimination, dynamic import(), and what breaks tree-shaking
15 Web Workers in React: Moving Heavy Work Off the Main Thread Message passing, transferable buffers, and patterns that keep the UI responsive
16 OPFS: The Browser's Built-in Filesystem for Large Blobs High-throughput local file I/O without blocking the main thread
17 Why CSS Never Matches Figma: Browser vs Canvas Pipelines Subpixel, font metrics, and why design tools and browsers diverge
18 JavaScript GC: Pauses, Allocation Rate, and Frontend Jank Generational GC, why allocation spikes cause hitches, and practical mitigation
19 React Memory Leaks: Closures, Subscriptions, and Object Graphs Retained graphs, common leak patterns, and AbortController-style cleanup
20 Lighthouse Score 100 and Still Crashes: OOM and Long Sessions Why perfect lab scores miss production OOM and multi-hour tab failures
21 Why React Error Boundaries Are Still Class Components getDerivedStateFromError and why error recovery is not a hook yet
22 Meta StyleX: Moving CSS-in-JS From Runtime to Build Time Atomic CSS at build time and eliminating runtime style injection
23 How to Answer Frontend Performance Questions in Interviews A 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
});
Enter fullscreen mode Exit fullscreen mode

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:


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

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 / Method Category
offsetWidth, offsetHeight Box model
offsetTop, offsetLeft, offsetParent Box model
clientWidth, clientHeight Box model
clientTop, clientLeft Box model
scrollWidth, scrollHeight Scroll
scrollTop, scrollLeft Scroll
getBoundingClientRect() Geometry
getComputedStyle() Computed style
innerWidth, innerHeight Window
scrollX, scrollY Window
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;
}
Enter fullscreen mode Exit fullscreen mode

The trap is applying it everywhere as a "performance optimization":

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

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
});
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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:

`js
// 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.

js
// 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

Approach Runs on CAN skip main thread? Best for
CSS transitions Compositor (if transform/opacity) Yes Simple A→B transitions
CSS animations (@keyframes) Compositor (if transform/opacity) Yes Looping animations, keyframe sequences
Web Animations API Compositor (if transform/opacity) Yes Programmatic animations with JS control
requestAnimationFrame + style writes Main thread No Complex physics, canvas, custom interpolation
GSAP, Framer Motion Main 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.


Read the original article on Renderlog.in:
https://renderlog.in/blog/frontend-performance-how-why-hub/

If you found this helpful, I've also built some free tools for developers and everyday users. Feel free to try them once:

JSON Tools: https://json.renderlog.in
Text Tools: https://text.renderlog.in
QR Tools: https://qr.renderlog.in

Top comments (0)