What techniques do you use to ensure performance and responsiveness in a web app?
Performance (loads fast) and responsiveness (feels snappy after load) need different fixes. Performance: SSR/SSG, code split, CDN, image optimization, preload critical, lazy below-fold. Responsiveness: avoid long tasks (>50ms blocks INP), useTransition for non-urgent updates, virtualize lists, debounce inputs, web workers for CPU work, requestAnimationFrame for animations. Measure both — LCP for load, INP for responsiveness — at p75 from real users.
"Performance" lumps two things together: load speed (LCP, FCP, TTI) and interaction snappiness (INP, smooth scroll/drag). They need different techniques.
Load speed
The page needs to arrive fast.
Server:
- SSR/SSG so HTML has content immediately.
- Edge SSR (Cloudflare Workers, Vercel Edge) to bring TTFB to ~100ms globally.
- CDN with long max-age on hashed assets.
- Brotli compression on text.
- HTTP/2 or HTTP/3.
Bundle:
- Code split per route + per heavy widget.
- Tree-shake unused exports.
- Modern JS for modern browsers.
- Audit deps quarterly; replace heavy with light.
Resources:
- Preload LCP image + critical fonts.
- AVIF/WebP for images, with srcset + sizes.
- Inline critical CSS; async-load the rest.
- Defer/async non-critical scripts.
- Lazy-load below-fold images and iframes.
Don't lazy-load the LCP image — it's the #1 LCP anti-pattern.
Responsiveness
After the page loads, the user expects clicks, taps, and inputs to respond within 100ms. INP measures this.
Main-thread hygiene:
- Long tasks (>50ms) block INP. Split them with
scheduler.yield()or chunked setTimeout. - Don't do CPU-heavy work in event handlers — push to web workers.
- Avoid layout thrash (interleaved layout reads + style writes).
Render perf:
- Virtualize long lists.
React.memo+ stable refs on expensive subtrees.useTransitionfor non-urgent updates so input stays responsive:
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
function onChange(e) {
setFilter(e.target.value); // urgent — input stays snappy
startTransition(() => {
setResults(slowFilter(items, e.target.value)); // deferred
});
}Animations:
- Use CSS
transform+opacity(compositor-only, no layout/paint). requestAnimationFramefor JS-driven animations.will-change: transformto promote to its own layer (use during animation, remove after).
Inputs:
- Debounce search/autocomplete.
- Throttle scroll/drag/mousemove handlers (or RAF-coalesce).
- Cancel previous in-flight requests on new keystrokes.
Combined: scrolling a long list
A long list both loads slowly and scrolls poorly without intervention.
- Virtualize (windowing) → constant DOM size regardless of data length.
- Paginate or cursor-fetch data → bounded memory.
- Memoize row components → no re-render on parent updates.
content-visibility: autoon offscreen sections → browser skips render.- AbortController on data fetches → cancel stale requests.
Web workers for CPU
const worker = new Worker('parse.js');
worker.postMessage(rawCsv);
worker.onmessage = e => setData(e.data);Heavy parse, compression, image manipulation off the main thread. Comlink wraps the postMessage API nicely.
Concurrent React features
useDeferredValue— defer dependent re-render until user stops typing.useTransition— mark state updates as low-priority.Suspense— show fallback while data/code loads, with concurrent rendering avoiding "render-then-suspend" jank.
What to measure
- Load: LCP, FCP, TTFB, total bytes, time to first paint.
- Responsiveness: INP, total blocking time, long-task count.
- Per device class + network + geo + route.
- p75 and p95.
Process
- Ship RUM.
- Find worst metric × worst page × worst segment.
- Profile (DevTools Performance + Coverage).
- Identify dominant cost: bundle parse, image, third-party, render.
- Apply the matching technique above.
- Validate metric movement on real users.
Common pitfalls
- Optimizing LCP but ignoring INP — users complain about laggy interactions even when load is fast.
- Memoizing everything in React — overhead without payoff.
- Lazy-loading the LCP image.
- Big third-party scripts (analytics, ads, chat) eating the perf budget.
- No CI guard — wins regress without anyone noticing.
Mental model
Two metrics, two playbooks. LCP/FCP fixes: ship less, ship earlier, serve faster. INP fixes: keep the main thread free, defer work, slice long tasks. Plan for both; measure both; never trade one off without knowing it.
Follow-up questions
- •What's the difference between LCP and INP investigation?
- •How does useTransition help INP?
- •When should you reach for a web worker?
- •What's content-visibility and when does it help?
Common mistakes
- •Treating perf as one metric instead of load + responsiveness.
- •Optimizing only Lighthouse score; ignoring INP.
- •Lazy-loading LCP image.
- •Animating top/left instead of transform.
- •Memoizing everything in React.
- •No CI perf budget.
Performance considerations
- •Compounding wins: smaller bundle → faster TTI → less long-task pressure → better INP → happier users. Web workers and virtualization decouple main-thread budget from data volume. Concurrent React features hide work from the user.
Edge cases
- •Low-end mobile devices have very different perf profiles — test on a throttled CPU.
- •Background tabs throttle timers but receive WS messages — design for that.
- •Service workers can serve cached content offline — perf doesn't apply the same way.
- •Bfcache (back/forward cache) makes return visits feel instant — design pages to be bfcache-eligible.
- •Low-power mode on iOS throttles JS aggressively.
Real-world examples
- •Linear's snappiness comes from local-first sync + tight INP discipline.
- •Figma uses Canvas/WebGL for the editor to bypass DOM render bottlenecks.
- •Pinterest cut both LCP and INP in their PWA rewrite.