Performance and accessibility
Learn what makes a React app feel slow, how to measure it before fixing it, and the handful of techniques that matter most. Then learn the basics of accessibility so keyboard and screen reader users can use your app too.
Two qualities separate a hobby project from a professional one: it is fast, and everyone can use it. Both are easy to ignore while you build on a fast laptop with a mouse. Your users may be on a budget Android phone, a patchy mobile network, or using a keyboard or a screen reader.
This lesson covers both. The golden rule for performance is: measure first, then fix. Most code does not need optimising, and guessing wastes time.
What 'fast' means: Core Web Vitals
Google defines three measurements of real user experience, called Core Web Vitals. The exact thresholds are published on web.dev, so check there for current values.
- LCP (Largest Contentful Paint): how long until the biggest thing on screen (usually a hero image or heading) appears. It is about loading. - INP (Interaction to Next Paint): how quickly the page responds visually after a click, tap or key press. It is about responsiveness. It replaced an older measure called FID. - CLS (Cumulative Layout Shift): how much the layout jumps around while loading, such as a button moving just as you tap it. It is about visual stability.
Cause and cure
| Symptom | Typical cause in React apps | Typical fix |
|---|---|---|
| Slow first load (bad LCP) | Huge JavaScript bundle, huge images | Code splitting, compress and resize images, server rendering |
| Laggy typing or clicking (bad INP) | Too much work on each render, thousands of DOM nodes | Reduce re-renders, virtualise long lists, move work out of the render |
| Content jumps (bad CLS) | Images without width and height, content injected late | Set image sizes, reserve space for loading content |
Measure first
Two free tools you already have
- 1Build a production version: run
npm run buildthennpm run preview(Vite). Development mode is slower and not representative. - 2Open Chrome DevTools and go to the Lighthouse tab. Choose Mobile, run an audit and read the Performance, Accessibility and SEO scores and the list of suggestions.
- 3Open the Network tab, throttle to a slow connection, and reload. Notice which files are biggest.
- 4Install the React Developer Tools browser extension. Use its Profiler tab: click record, use the slow part of your app, stop, and see which components rendered and how long each took.
Test on a slow device
In Chrome DevTools you can throttle the CPU (Performance tab settings) and the network. Many Indian users are on mid-range phones and variable mobile data, so a page that feels instant on your laptop can feel slow on theirs.
Technique 1: do less on first load (code splitting)
By default, your bundler puts all your code into a few big files that every visitor downloads, even for pages they never open. Code splitting loads a page's code only when it is needed. React gives you lazy and Suspense, and Vite splits the bundle at each dynamic import().
The best place to split is routes: the admin page's code need not load for a visitor to the home page.
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home.jsx';
// Loaded only when the user first visits /reports
const Reports = lazy(() => import('./pages/Reports.jsx'));
export default function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}lazy needs a component that is the default export of the imported file. Suspense shows the fallback while the code downloads. After npm run build, Vite prints a separate file for Reports.
Technique 2: send smaller images
Images are usually the heaviest part of a page. A few habits help a lot:
- Resize images to the size they are shown. Do not ship a 4000-pixel photo for a 300-pixel card.
- Use modern formats such as WebP or AVIF where you can, and compress them.
- Add loading="lazy" to images that are below the fold so they load only when scrolled near.
- Always give width and height (or an aspect ratio) so the browser reserves space and the layout does not jump.
<img
src={recipe.image}
alt={`Photo of ${recipe.name}`}
width={300}
height={200}
loading="lazy"
/>Do not lazy-load the main image at the top of the page; that is the one that decides your LCP.
Technique 3: avoid wasted re-renders
A component re-renders when its state changes, when its parent re-renders, or when a context it reads changes. Re-rendering is normally cheap, and React is fast. It becomes a problem when a render is expensive or happens very often.
Three tools exist for the problem cases:
- React.memo(Component) skips re-rendering a component if its props are the same as last time.
- useMemo(() => compute(), [deps]) remembers the result of an expensive calculation.
- useCallback(fn, [deps]) remembers a function, so a memoised child receives the same prop each time.
They work together: memo compares props by identity, and a function or object created inline is a new value each render, so useCallback or useMemo keeps it stable.
import { memo, useCallback, useMemo, useState } from 'react';
const RecipeRow = memo(function RecipeRow({ recipe, onSelect }) {
console.log('render', recipe.name);
return <li onClick={() => onSelect(recipe.id)}>{recipe.name}</li>;
});
export default function RecipeList({ recipes }) {
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState(null);
// Recalculated only when recipes or query change
const visible = useMemo(
() => recipes.filter((r) => r.name.toLowerCase().includes(query.toLowerCase())),
[recipes, query]
);
// Same function identity on every render, so memo can skip rows
const handleSelect = useCallback((id) => setSelectedId(id), []);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search recipes" />
<p>Selected: {selectedId ?? 'none'}</p>
<ul>
{visible.map((r) => (
<RecipeRow key={r.id} recipe={r} onSelect={handleSelect} />
))}
</ul>
</>
);
}Click a row: only the component state of the parent changes, and the console shows no 'render' lines for unchanged rows. Remove memo or useCallback and you will see every row log again.
Don't wrap everything
memo, useMemo and useCallback have a cost of their own and make code harder to read. Use them when the Profiler shows a real problem, not by habit. Newer React versions also offer an optional build-time tool called the React Compiler that adds this kind of memoisation automatically; check the React documentation for its current status before relying on it. Another cause of waste is state placed too high: if only a search box needs query, keep it in that component so typing does not re-render the whole page.
Technique 4: long lists
Rendering 5,000 rows creates 5,000 sets of DOM nodes, and the page becomes slow no matter how clean your code is. Fix this by showing less: pagination (pages of 20), a load more button, or virtualisation, which renders only the rows visible in the window. Libraries such as @tanstack/react-virtual or react-window do virtualisation. Always make sure each item has a stable key (an id, not the array index, if the list can be reordered or filtered).
Technique 5: don't fire a request on every keystroke
A search box that calls an API on every key press wastes bandwidth and shows flickering results. Debounce it: wait until the user stops typing for a moment (for example 300 ms) and then search. You can build a small useDebounce hook or use one from a library.
import { useEffect, useState } from 'react';
export function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // cancel if value changes again
}, [value, delay]);
return debounced;
}Use it as: const debouncedQuery = useDebounce(query); then fetch using debouncedQuery in your effect or query key.
Accessibility (a11y)
Accessibility means people with different abilities can use your app: someone blind using a screen reader, someone who cannot use a mouse, someone with low vision or colour blindness, someone with a temporary injury. It also helps everyone, for example keyboard shortcuts and good contrast in bright sunlight. In some countries it is a legal requirement for certain services, and many employers test for it.
The good news: most of it comes from using HTML correctly.
The most important habits
| Habit | Do this | Not this |
|---|---|---|
| Use real elements | <button onClick>, <a href>, <nav>, <main>, <h1> to <h3> in order | <div onClick> styled to look like a button |
| Label every form field | <label htmlFor="phone">Phone</label> with <input id="phone"> | A placeholder as the only label |
| Describe images | alt="Masala chai in a glass"; use alt="" for purely decorative images | Missing alt, or alt="image" |
| Keep keyboard access | Everything usable with Tab, Enter and Space; keep the visible focus outline | outline: none with no replacement |
| Enough contrast | Text clearly readable against its background (WCAG suggests a ratio of at least 4.5:1 for normal text) | Light grey text on white |
| Don't rely on colour alone | Error text and an icon, not just a red border | Red border only |
// Bad: a div is not focusable and has no role, so keyboard and screen reader users cannot use it
<div className="btn" onClick={addToCart}>Add</div>
// Good: a button is focusable, works with Enter and Space and is announced as a button
<button type="button" onClick={addToCart}>
Add Masala Chai to cart
</button>
// Icon-only button needs a name
<button type="button" onClick={closeDialog} aria-label="Close dialog">
<span aria-hidden="true">x</span>
</button>ARIA attributes (aria-label, aria-pressed, role) add information when no native element fits. The first rule of ARIA is to use a native HTML element if one exists.
// Tell screen readers when a status message appears
<p role="status">{message}</p>
// Errors that should interrupt
<p role="alert">{error}</p>
// Let keyboard users skip the navigation (first element in the body)
<a href="#main" className="skip-link">Skip to content</a>
<main id="main">...</main>role="status" is polite and waits for the user to finish; role="alert" is assertive. Use alert sparingly.
Test accessibility in five minutes
- 1Put your mouse away. Use only Tab, Shift+Tab, Enter and Space to complete the main task in your app. Can you reach everything? Can you always see where the focus is?
- 2Run the Lighthouse Accessibility audit and fix what it reports.
- 3Install the free axe DevTools browser extension and run a scan on each page.
- 4Add
eslint-plugin-jsx-a11y, which catches issues such as missing alt text while you type (many starter templates andnext lintsetups include it, so check yours first). - 5Try a screen reader for a few minutes: NVDA is free on Windows, VoiceOver is built into Mac and iPhone, and TalkBack is built into Android.
How this comes up in interviews
Common questions: "How would you find why a React page is slow?" (answer: measure with the Profiler and Lighthouse first), "What is the difference between useMemo and useCallback?", "What does React.memo do and when is it useless?" (it does nothing if you pass a new object or function every time), "How do you split code?", "What are Core Web Vitals?", and "How do you make a custom dropdown accessible?" (start with native elements, then keyboard support, then ARIA).
Try it: Audit your own app
Pick an app you built. Run npm run build and npm run preview, then:
1. Run Lighthouse on mobile. Write down the Performance and Accessibility scores. 2. Fix one performance issue (for example lazy-load a route or resize an image) and one accessibility issue (for example add a missing label). 3. Run Lighthouse again and compare. 4. Complete one task using only the keyboard and list any place you got stuck.
You are an accessibility specialist who knows WCAG and React. Review this component for accessibility problems that affect keyboard users and screen reader users. For each problem, give the line, why it is a problem, and a corrected version. Prefer native HTML elements over ARIA.
```
[paste your component code]
```What to expect: Check the advice yourself with the keyboard and a scan tool. AI review misses issues that need real testing, such as focus order and contrast.
Key takeaways
- Measure before optimising: Lighthouse, the Network tab and the React Profiler, on a production build.
- Core Web Vitals: LCP (loading), INP (responsiveness), CLS (visual stability).
- Biggest wins are usually smaller bundles (route-level
lazy), right-sized images and keeping state low. memo,useMemoanduseCallbackhelp only when a real re-render problem exists.- Long lists need pagination or virtualisation; search inputs need debouncing.
- Accessibility starts with correct HTML: real buttons and links, labels, alt text, keyboard support and contrast.
Check yourself
Finishing the quiz marks the lesson as done.
1. A page feels slow. What should you do first?
2. Which technique loads the code for an admin page only when someone visits it?
3. React.memo on a child seems to do nothing, because the parent passes onSelect={() => select(id)} inline. Why?
4. Which is the most accessible way to make a clickable 'Add to cart' control?
5. A form input has only a placeholder text 'Phone number' and no label. What is the problem?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.