How to read this
Every technique below follows the same shape
Optimization isn't about doing everything β it's about knowing which lever fixes which bottleneck. Each technique here is written as:
The specific pain it addresses β what's slow or wasteful, and why.
The mechanism that fixes it, with a minimal code sketch.
Measure first. Use the Performance panel / Lighthouse / React Profiler to find the real bottleneck before applying any of these. Premature optimization adds complexity for no gain.
1. Event Handling
Taming high-frequency events: debounce & throttle
1.1 Debouncing
A handler fires on every event in a rapid burst β each keystroke in a search box triggers an API call, every resize recomputes layout. Most of that work is thrown away; it wastes CPU, network, and causes jank.
Wait until the events stop for N milliseconds, then run the handler once. Each new event resets the timer β so you only act after the user pauses.
function debounce(fn, delay = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// search: fire the API call only after typing pauses for 300ms
input.addEventListener('input', debounce(e => search(e.target.value), 300));
Use for: search-as-you-type, autosave, validating a field, window resize recalculations.
1.2 Throttling
A continuous stream of events (scroll, mousemove, drag) fires dozens of times per second. Even a cheap handler run that often blows the frame budget and stutters.
Run the handler at most once every N ms, no matter how many events arrive. Unlike debounce (which waits for silence), throttle fires at a steady cadence during the activity.
function throttle(fn, limit = 100) {
let waiting = false;
return (...args) => {
if (waiting) return;
fn(...args);
waiting = true;
setTimeout(() => (waiting = false), limit);
};
}
window.addEventListener('scroll', throttle(updateScrollProgress, 100));
Debounce vs throttle: debounce = "wait until they're done" (search); throttle = "steady updates while it happens" (scroll position, infinite-scroll triggers).
2. Rendering Big Lists
Virtualization & loading data in chunks
2.1 Virtualization (windowing)
Rendering a list of 10,000 rows creates 10,000 DOM nodes. Mounting is slow, memory balloons, and scrolling janks β even though the user can only see ~15 rows at a time.
Render only the rows in (and just around) the visible window. As the user scrolls, recycle nodes and swap in new data. Empty spacer divs above/below preserve the correct scrollbar height.
import { FixedSizeList } from 'react-window';
<FixedSizeList height={600} width="100%" itemCount={10000} itemSize={40}>
{({ index, style }) => <div style={style}>Row {index}</div>}
</FixedSizeList>
// only ~20 rows exist in the DOM at any time, regardless of itemCount
Tools: react-window, react-virtuoso, TanStack Virtual. Use for: long feeds, tables, chat logs, dropdowns with thousands of options.
2.2 Pagination & infinite scroll
Fetching and rendering an entire dataset up front means a slow first load and a huge payload β most of which the user never scrolls to.
Load data in chunks on demand β classic pages, or infinite scroll that fetches the next batch when a sentinel near the bottom enters view (IntersectionObserver). Pairs perfectly with virtualization.
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) loadNextPage();
});
io.observe(document.querySelector('#load-more-sentinel'));
3. React Re-render Optimization
Cutting wasted renders & recomputation
3.1 Memoization (memo / useMemo / useCallback)
When a parent re-renders, React re-runs all children and re-computes values β even when their inputs didn't change. For expensive subtrees or heavy calculations, that's wasted work every render.
React.memo skips a child whose props are shallow-equal; useMemo caches an expensive computed value; useCallback keeps a function reference stable so a memoized child actually skips. They're a system β stable refs make memo work.
const Child = React.memo(function Child({ onPick, rows }) { /* ... */ });
function Parent({ data }) {
const rows = useMemo(() => expensiveTransform(data), [data]); // cache value
const onPick = useCallback(id => select(id), []); // stable ref
return <Child rows={rows} onPick={onPick} />; // Child can skip re-render
}
Memoization costs memory + a comparison every render. Don't sprinkle it everywhere β measure with the React Profiler first. React 19's Compiler auto-inserts this for you. Deep dive β
3.2 Stable keys & references
Using array index as a list key causes wrong-row state and extra DOM work on reorders. Passing fresh {}/[]/arrow props defeats React.memo (props always look "changed").
Key lists by stable data identity (item.id), and pass stable references (via useMemo/useCallback) to memoized children. Lift state only as high as needed to avoid re-rendering large subtrees.
4. Load Less, Sooner
Shipping & fetching only what's needed, when it's needed
4.1 Code splitting & lazy loading
A single huge JS bundle must download, parse, and execute before the page is interactive β even code for routes the user may never visit.
Split the bundle along route/component boundaries with dynamic import(), and load each chunk only when needed. In React, lazy + Suspense handles the loading state.
const Dashboard = React.lazy(() => import('./Dashboard'));
<Suspense fallback={<Spinner />}>
<Dashboard /> {/* its JS downloads only when this route renders */}
</Suspense>
4.2 Tree shaking
Bundles ship dead code β unused exports and whole modules pulled in by a single import.
Use ES modules (static import/export) so bundlers can statically drop unused exports. Import named members, not whole libraries, and mark packages "sideEffects": false in package.json.
import { debounce } from 'lodash-es'; // β
only debounce is bundled
import _ from 'lodash'; // β pulls the whole library
4.3 Resource hints (preload / prefetch / preconnect)
The browser discovers critical resources (fonts, hero image, next-page JS) late, because they're referenced deep in the HTML/CSS β adding round-trips on the critical path.
Tell the browser early. preload fetches a critical resource now; prefetch grabs a likely-next resource at low priority; preconnect warms up a connection (DNS/TLS) to a third-party origin.
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>
<link rel="prefetch" href="/dashboard.js">
<link rel="preconnect" href="https://api.example.com">
5. Assets & Network
Images, compression, and caching
5.1 Image optimization
Images are usually the heaviest thing on a page. Oversized, wrong-format images blow up load time and bandwidth β and missing dimensions cause layout shift (CLS).
Serve modern formats (WebP/AVIF), the right size per device via srcset/sizes, defer offscreen images with loading="lazy", and always set width/height (or aspect-ratio) to reserve space.
<img src="hero-800.webp" width="800" height="450" loading="lazy"
srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
sizes="(max-width: 600px) 100vw, 800px" alt="...">
5.2 Minification & compression
Raw JS/CSS/HTML ships with whitespace, comments, and long names, and travels uncompressed β far more bytes than necessary.
Minify assets at build time (strip whitespace, mangle names) and enable Brotli/gzip compression at the server/CDN. Together they often cut text payloads by 70%+.
5.3 Caching (HTTP, CDN, service worker)
Re-downloading unchanged assets on every visit wastes bandwidth and time, and serving everything from one origin adds latency for distant users.
Use content-hashed filenames + long Cache-Control: immutable so browsers reuse assets safely; put static files on a CDN near users; add a service worker for offline/instant repeat loads.
app.4f3a9b.js β Cache-Control: public, max-age=31536000, immutable
index.html β Cache-Control: no-cache (always revalidate the entry point)
See also the dedicated Caching Guide.
6. Keeping the Main Thread Free
Web Workers, rAF, and avoiding layout thrashing
6.1 Web Workers
JS is single-threaded. A heavy computation (parsing, image processing, big sort) freezes the entire UI β no scrolling, no clicks, no painting β until it finishes.
Move CPU-heavy work to a Web Worker β a separate thread. The UI thread stays responsive and gets the result via postMessage.
// main thread
const worker = new Worker('crunch.js');
worker.postMessage(bigDataset);
worker.onmessage = e => render(e.data); // UI never froze
// crunch.js
onmessage = e => postMessage(heavyCompute(e.data));
6.2 requestAnimationFrame & batching
Animating with setTimeout, or writing to the DOM many times per frame, produces dropped frames and unnecessary work that isn't aligned to the screen refresh.
Schedule visual updates in requestAnimationFrame so they run once per frame, right before paint. Batch DOM writes together inside the callback.
function onScroll() {
requestAnimationFrame(() => { // coalesce work to one per frame
header.style.transform = `translateY(${offset}px)`;
});
}
6.3 Avoid layout thrashing
Interleaving DOM reads (offsetWidth, getBoundingClientRect) with writes forces the browser to reflow synchronously on every read β turning one reflow into N.
Batch all reads, then all writes. Prefer compositor-only properties (transform, opacity) that skip layout & paint entirely.
const widths = items.map(el => el.offsetWidth); // read phase
items.forEach((el, i) => el.style.width = widths[i]*2 + 'px'); // write phase β 1 reflow
Full mechanics in React Internals β Render Cost & Reflow.
7. Perceived Performance
Making it feel fast, even when work is happening
7.1 Skeleton screens
A blank screen or a spinner during loading feels slow and gives no sense of progress or layout.
Show skeleton placeholders shaped like the real content. Users perceive the page as loading faster because structure appears immediately.
7.2 Optimistic UI
Waiting for a server round-trip before showing the result (like, comment, toggle) makes the app feel laggy.
Update the UI immediately with the expected result, send the request in the background, and roll back if it fails. React 19's useOptimistic formalizes this.
const [optimisticLikes, addOptimistic] = useOptimistic(likes);
function like() {
addOptimistic(likes + 1); // instant UI
saveLike(); // reconciles / reverts on failure
}
7.3 Suspense, transitions & deferred values
Blocking the whole screen until all data is ready, or letting an expensive update (filtering a huge list) freeze typing.
Stream content with Suspense boundaries, and mark non-urgent updates with startTransition/useDeferredValue so urgent input stays responsive. React 18/19 β
Summary β technique β problem
The cheat sheet
| Technique | Fixes |
|---|---|
| Debounce | Handler fires on every event in a burst β run once after it stops. |
| Throttle | Continuous events overrun the frame budget β cap to once per N ms. |
| Virtualization | Thousands of DOM nodes β render only the visible window. |
| Pagination / infinite scroll | Loading the whole dataset β fetch in on-demand chunks. |
| Memoization | Wasted re-renders & recomputation β cache output/value/ref. |
| Stable keys & refs | Wrong-row state & broken memo β data-id keys, stable props. |
| Code splitting / lazy | Huge upfront bundle β load chunks on demand. |
| Tree shaking | Dead code shipped β drop unused exports (ESM, named imports). |
| Resource hints | Critical resources found late β preload/prefetch/preconnect. |
| Image optimization | Heaviest asset β modern formats, srcset, lazy, set dimensions. |
| Minify + compress | Oversized text payloads β minify + Brotli/gzip. |
| Caching / CDN / SW | Refetching unchanged assets β hashed names + immutable cache + CDN. |
| Web Workers | Heavy compute freezes UI β offload to another thread. |
| requestAnimationFrame | Janky animation / scattered writes β one batched update per frame. |
| Avoid layout thrashing | Forced sync reflows β batch reads then writes; use transform/opacity. |
| Skeletons / optimistic UI | Feels slow while waiting β show structure / result instantly. |
Three buckets: do less (split, tree-shake, debounce, virtualize), do it off the critical path (workers, rAF, lazy, prefetch), and make it feel instant (skeletons, optimistic UI). Always profile before and after.