Web Optimisation
What optimisation is: reduce time, CPU, memory and network work
required to get a usable site and keep it responsive.
Goal: improve user-perceived speed (first meaningful paint, interactivity)
and runtime smoothness while balancing maintainability.
Three layers to think in (always):
1. Build / Network — what bytes are sent (bundle size, images, fonts,
compression).
2. Load / Parse — how fast the browser parses and executes JS/CSS
(code-splitting, tree-shaking).
3. Run / Render — how often and how expensively the app updates the
DOM (React re-renders, layout/paint).
Key principle: measure first, change one thing, measure again. Always
prove improvement.
Tools & key metrics (what to use & what to read)
Core metrics to know
- LCP (Largest Contentful Paint) — perceived load speed.
- INP / FID (Interaction to Next Paint / First Input Delay) —
responsiveness.
- CLS (Cumulative Layout Shift) — visual stability.
- TTFB, FCP, TTI, TBT — server/first paint/interactive/total blocking
time.
Tools
- Chrome DevTools (Performance tab, Network tab) — record, inspect
main thread, flame charts.
- Lighthouse (DevTools or CLI) — lab audit and suggestions.
- React DevTools Profiler — which components re-render and why.
- WebPageTest / PageSpeed Insights — synthetic and field metrics.
- Bundle analyser (webpack-bundle-analyzer, rollup plugin) — inspect
bundles.
Quick how-to (DevTools):
- Open page → DevTools → Performance → Record → interact → stop.
- See Main thread, scripting tasks, long tasks (>50ms).
- Use React DevTools → Profiler → record 5–10s while interacting to
identify heavy components.
Network & build optimisations (step-by-step)
1. Measure baseline: Lighthouse score + bundle size + network waterfall.
2. Minify & compress: ensure gzip/brotli on server.
3. Enable tree-shaking (ES modules) — ensure build tool supports it
(Webpack/Rollup/Vite).
4. Code-splitting / route-based chunking
◦ Example (React lazy):
// Before: all loaded
import Dashboard from './Dashboard';
// After: split into separate chunks
const Dashboard = React.lazy(() => import('./Dashboard'));
// Usage
<Suspense fallback={<Spinner/>}>
<Dashboard />
</Suspense>
5. Dynamic imports for big libs: import charts, maps only when needed.
6. Preload / Prefetch: preload critical assets, prefetch next-route
chunks.
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2"
crossorigin>
<link rel="prefetch" href="/route-next.chunk.js">
7. Serve via CDN and set long cache headers for immutable assets
(Cache-Control: public, max-age=31536000, immutable).
8. Optimise bundles: remove polyfills, replace heavy libs with lighter
alternatives (e.g., date-fns instead of moment).
Asset optimisation (images, fonts, media)
• Images
- Use next-gen formats: WebP/AVIF for photos.
- Serve responsive sizes with srcset and sizes.
- Use loading="lazy" for offscreen images.
- Use a resizing CDN or sharp to generate multiple sizes.
- Example srcset:
<img src="img-800.jpg"
srcset="img-400.jpg 400w, img-800.jpg 800w, img-1200.jpg 1200w"
sizes="(max-width:600px) 400px, 800px"
loading="lazy" alt="">
• Fonts
- Subset fonts, use font-display: swap, preload critical fonts.
• Videos: lazy load, use low-res poster, provide compressed codec
options.
Run-time & DOM optimisations (browser-level)
• Reduce DOM nodes — smaller trees render faster.
• Avoid layout thrashing: read/write DOM in batches; prefer transform/
opacity for animations (GPU).
• Avoid expensive CSS selectors and heavy paint properties.
• Debounce / throttle expensive event handlers (scroll, resize, input).
Debounce example:
function debounce(fn, wait) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}
React-specific optimisation (deep, step-by-step)
Understand how React renders:
- React computes a virtual DOM diff and updates the real DOM when
necessary.
- Re-render happens when parent state/props change or context value
changes.
Stepwise checklist to optimise a React app
1. Find the slow component — use React Profiler + DevTools.
2. Isolate cause — network, render, or layout? (DevTools timeline
helps).
3. Apply appropriate pattern (memo, virtualization, code-split).
4. Measure again.
Common React patterns & examples
1. React.memo — prevents re-render of a function component when
props are shallowly equal.
const Child = React.memo(({ value }) => {
console.log('child render');
return <div>{value}</div>;
});
Only use when the child is moderately expensive to render or receives
props that rarely change.
2. useCallback — memoise functions to keep a stable reference when
passing to memoized children.
function Parent() {
const [count, setCount] = useState(0);
// Without useCallback, increment is recreated every render => child re-
renders
const increment = useCallback(() => setCount(c => c + 1), []);
return <Child onClick={increment} />;
}
Important: useCallback itself costs memory; don’t overuse—measure
first.
3. useMemo — memoise expensive computed values.
const expensive = useMemo(() => heavyCalc(data), [data]);
Don’t wrap cheap code; memoization cost must be worth the saved re-
calculation.
4. Virtualisation — for long lists, use react-window / react-virtualized.
import { FixedSizeList as List } from 'react-window';
<List height={400} itemCount={10000} itemSize={35} width={300}>
{({ index, style }) => <div style={style}>Item {index}</div>}
</List>
5. Avoid inline props/objects – creating const obj = { a: 1 } inline will
create new reference each render and may break memoization. Either
memoise the object with useMemo or move it outside.
6. Keys in lists — avoid key={index} when list changes order; use stable
ids.
7. State locality — keep the state close to where it’s used. Lifting the
state up unnecessarily can cause many re-renders.
8. Context — useful, but updating context value re-renders consumers.
Split contexts or memoise context values.
9. Batching & Transition (Concurrent features) — In modern React,
state updates are batched. Use startTransition for non-urgent
updates:
import { startTransition } from 'react';
startTransition(() => {
setBigState(newValue);
});
Concrete “before & after” mini-examples
A. Unnecessary child re-render
// Before
function Parent() {
const [count, setCount] = useState(0);
const increment = () => setCount(c => c + 1); // new fn every render
return <>
<button onClick={increment}>+</button>
<Child onClick={increment} />
</>
}
const Child = React.memo(({ onClick }) => { ... });
fix
const increment = useCallback(() => setCount(c => c + 1), []);
B. Heavy compute in render
function Component({ items }) {
const result = items.map(i => heavyCalc(i)); // expensive every render
return <List data={result} />;
}
fix
const result = useMemo(() => items.map(i => heavyCalc(i)), [items]);
Practical step-by-step workflow (apply to a real app)
1. Baseline
- Run Lighthouse & React Profiler. Save reports.
1. Pick top 2-3 bottlenecks (e.g., big JS bundle, long main-thread
tasks, long list re-renders).
2. Implement fixes one by one
- Bundle: enable code-splitting, remove unused libs.
- Long renders: memoise heavy components, virtualise lists.
- Network: compress images & text, add caching headers.
1. Re-measure (Lighthouse + Profiler). Compare.
2. If fixed, ship and monitor real users (RUM): add analytics for LCP/
TBT/CLS.
3. Repeat every sprint (small incremental wins).
Hands-on exercises (do these to internalise)
1. Measure baseline
- Pick one app/page. Record Lighthouse and React Profiler output.
1. Find a re-render hotspot
- Use React Profiler to find a component that re-renders often. Try
React.memo + useCallback and re-run profiler.
1. Virtualise a long list
- Create a 10,000-item list and measure FPS/CPU. Add react-window
and compare.
1. Code-split a route
- Convert a heavy route to React.lazy + Suspense.
1. Image optimisation
- Replace a hero image with responsive srcset and WebP; measure
LCP.
1. Critical CSS & fonts
- Preload main font, set font-display: swap, measure CLS.
1. Debounce input
- Implement a search input that debounces network requests.
Common anti-patterns & mistakes
- Premature memoisation (memo every component).
- Passing inline objects/functions to memoised children without
useMemo/useCallback.
- Using an index as a key for dynamic lists.
- Storing everything in global state (causes wide re-renders).
- Doing heavy synchronous work during render instead of async or
memoised.
- Loading large images at full resolution and resizing client-side.
Decision heuristics — when to apply what
- If bundle > 200–300 KB (gzipped) → code-split, replace heavy libs.
- If long tasks (>50ms) show in the main thread → find JS work and
memoise or defer.
- If list > ~500 visible or 1000 total → virtualise.
- If many small network requests → batch or cache.
Quick cheatsheet (actionable checklist)
1. Measure (Lighthouse + Profiler).
2. Fix the top 3 bottlenecks only.
3. Compress text (gzip/brotli) & images (WebP).
4. Code-split and lazy load non-critical code.
5. Memoise expensive renders only after measuring.
6. Virtualise large lists.
7. Localise state and avoid prop-drilling.
8. Use CDN and set caching headers.
9. Preload critical fonts and assets.
10. Re-run profiler and
React Optimisation Before/After Repo Plan
1. Code Splitting
Before
import Dashboard from './Dashboard';
export default function App() {
return <Dashboard />;
}
After
import React, { Suspense } from 'react';
const Dashboard = React.lazy(() => import('./Dashboard'));
export default function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
);
}
2. React.memo
Before
export default function Button({ onClick }) {
console.log('Button rendered');
return <button onClick={onClick}>Click</button>;
}
After
import React from 'react';
const Button = React.memo(function Button({ onClick }) {
console.log('Button rendered');
return <button onClick={onClick}>Click</button>;
});
export default Button;
3. useCallback
Before
export default function App() {
const handleClick = () => console.log('clicked');
return <Button onClick={handleClick} />;
}
After
import { useCallback } from 'react';
export default function App() {
const handleClick = useCallback(() => console.log('clicked'), []);
return <Button onClick={handleClick} />;
}
4. useMemo
Before
function slowFunction(num) {
console.log('Calling slow function...');
return num * 2;
}
export default function App({ num }) {
const result = slowFunction(num);
return <div>{result}</div>;
}
After
import { useMemo } from 'react';
function slowFunction(num) {
console.log('Calling slow function...');
return num * 2;
}
export default function App({ num }) {
const result = useMemo(() => slowFunction(num), [num]);
return <div>{result}</div>;
}
5. List Virtualisation
Before
export default function App() {
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);
return (
<div>
{items.map((item) => (
<div key={item}>{item}</div>
))}
</div>
);
}
After
import { FixedSizeList as List } from 'react-window';
export default function App() {
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);
return (
<List
height={500}
itemCount={items.length}
itemSize={35}
width={300}
>
{({ index, style }) => <div style={style}>{items[index]}</div>}
</List>
);
}
6. Lazy Image Loading
Before
export default function ImageComponent() {
return <img src="large-image.jpg" alt="big" />;
}
After
export default function ImageComponent() {
return <img src="large-image.jpg" loading="lazy" alt="big" />;
}
7. Debouncing
Before
export default function Search() {
const handleChange = (e) => {
fetch(`/api/search?q=${e.target.value}`);
};
return <input onChange={handleChange} />;
}
After
import { useCallback } from 'react';
import debounce from 'lodash.debounce';
export default function Search() {
const handleChange = useCallback(
debounce((value) => {
fetch(`/api/search?q=${value}`);
}, 300),
[]
);
return <input onChange={(e) => handleChange(e.target.value)} />;
}