⚑ Frontend Optimization Techniques

A practical playbook β€” for each technique: what problem it solves, and how it solves it.

πŸ—ΊοΈ

Optimization isn't about doing everything β€” it's about knowing which lever fixes which bottleneck. Each technique here is written as:

⚠️ Problem

The specific pain it addresses β€” what's slow or wasteful, and why.

βœ… Solution

The mechanism that fixes it, with a minimal code sketch.

πŸ’‘ Golden rule

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.1 Debouncing

⚠️ Problem

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.

βœ… Solution

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

⚠️ Problem

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.

βœ… Solution

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).

Events firing rapidly (e.g. keystrokes / scroll) events Debounce fires after pause run (1Γ—) run (1Γ—) Throttle steady cadence
Debounce collapses a burst into one run after it ends; throttle runs at a fixed rate throughout.
πŸ“œ

2.1 Virtualization (windowing)

⚠️ Problem

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.

βœ… Solution

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
All 10,000 items viewport render only these ~20 nodes actually in the DOM spacer (height of rows above) Row 142 Row 143 Row 144 spacer (height of rows below)
Spacers fake the full scroll height; only the windowed rows are real DOM nodes.

Tools: react-window, react-virtuoso, TanStack Virtual. Use for: long feeds, tables, chat logs, dropdowns with thousands of options.

2.2 Pagination & infinite scroll

⚠️ Problem

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.

βœ… Solution

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.1 Memoization (memo / useMemo / useCallback)

⚠️ Problem

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.

βœ… Solution

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
}
πŸ’‘ Caveat

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

⚠️ Problem

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").

βœ… Solution

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.1 Code splitting & lazy loading

⚠️ Problem

A single huge JS bundle must download, parse, and execute before the page is interactive β€” even code for routes the user may never visit.

βœ… Solution

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

⚠️ Problem

Bundles ship dead code β€” unused exports and whole modules pulled in by a single import.

βœ… Solution

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)

⚠️ Problem

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.

βœ… Solution

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.1 Image optimization

⚠️ Problem

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).

βœ… Solution

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

⚠️ Problem

Raw JS/CSS/HTML ships with whitespace, comments, and long names, and travels uncompressed β€” far more bytes than necessary.

βœ… Solution

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)

⚠️ Problem

Re-downloading unchanged assets on every visit wastes bandwidth and time, and serving everything from one origin adds latency for distant users.

βœ… Solution

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.1 Web Workers

⚠️ Problem

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.

βœ… Solution

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

⚠️ Problem

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.

βœ… Solution

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

⚠️ Problem

Interleaving DOM reads (offsetWidth, getBoundingClientRect) with writes forces the browser to reflow synchronously on every read β€” turning one reflow into N.

βœ… Solution

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.1 Skeleton screens

⚠️ Problem

A blank screen or a spinner during loading feels slow and gives no sense of progress or layout.

βœ… Solution

Show skeleton placeholders shaped like the real content. Users perceive the page as loading faster because structure appears immediately.

7.2 Optimistic UI

⚠️ Problem

Waiting for a server round-trip before showing the result (like, comment, toggle) makes the app feel laggy.

βœ… Solution

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

⚠️ Problem

Blocking the whole screen until all data is ready, or letting an expensive update (filtering a huge list) freeze typing.

βœ… Solution

Stream content with Suspense boundaries, and mark non-urgent updates with startTransition/useDeferredValue so urgent input stays responsive. React 18/19 β†’

βˆ‘
TechniqueFixes
DebounceHandler fires on every event in a burst β†’ run once after it stops.
ThrottleContinuous events overrun the frame budget β†’ cap to once per N ms.
VirtualizationThousands of DOM nodes β†’ render only the visible window.
Pagination / infinite scrollLoading the whole dataset β†’ fetch in on-demand chunks.
MemoizationWasted re-renders & recomputation β†’ cache output/value/ref.
Stable keys & refsWrong-row state & broken memo β†’ data-id keys, stable props.
Code splitting / lazyHuge upfront bundle β†’ load chunks on demand.
Tree shakingDead code shipped β†’ drop unused exports (ESM, named imports).
Resource hintsCritical resources found late β†’ preload/prefetch/preconnect.
Image optimizationHeaviest asset β†’ modern formats, srcset, lazy, set dimensions.
Minify + compressOversized text payloads β†’ minify + Brotli/gzip.
Caching / CDN / SWRefetching unchanged assets β†’ hashed names + immutable cache + CDN.
Web WorkersHeavy compute freezes UI β†’ offload to another thread.
requestAnimationFrameJanky animation / scattered writes β†’ one batched update per frame.
Avoid layout thrashingForced sync reflows β†’ batch reads then writes; use transform/opacity.
Skeletons / optimistic UIFeels slow while waiting β†’ show structure / result instantly.
βœ… Remember

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.