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.
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
});
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 / 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;
}
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:
- Open DevTools → three-dot menu → More tools → Rendering
- 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-shadowandborder-radiuson elements that animatebackground-colorchanges on large elements- CSS transitions on properties that aren’t compositor-only (
width,height,top,leftinstead oftransform)
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
| 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):
- Browser Rendering Pipeline: how the page becomes pixels (parse → style → layout → paint → composite)
- The 16.6ms Frame Budget: the timing constraint everything else is measured against
- 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.