These are usually two posts. They are one here because they are the same question asked twice: can the person on the other end actually use this? Someone on a three-year-old phone on a bad connection and someone navigating by keyboard are both people your app can quietly exclude, and in both cases you will never hear about it — they just leave.
Both also share a property that makes them worth learning early: nearly all of the cost is in decisions made while writing the code, and nearly all of the pain is in retrofitting.
Part one: performance
Your machine is a lie
You develop on a fast laptop, on fast wifi, next to the server, with a warm cache. Your users are not. Before optimising anything, look at the app the way they see it: open developer tools, throttle the network to "Slow 4G" and the CPU to a 4× slowdown, and reload with the cache disabled.
Most people find their app unusable for the first several seconds and had no idea.
The biggest cost is the JavaScript you ship
Every kilobyte is downloaded, parsed, compiled and executed on that phone before your app does anything. This dominates almost everything else, and the fix is not to make the code faster — it is to send less of it.
Code splitting is the main tool. Split at the route, because that maps to what someone actually needs right now:
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
const AdminReportsPage = lazy(() => import('./pages/admin/AdminReportsPage'));The demo app's reasoning:
React.lazy turns this import into a separate bundle that is fetched only when the route is first visited. Customers — the overwhelming majority of visitors — never open /admin, so its code should not be part of the JavaScript everyone downloads on the home page.
Because the import is asynchronous, something has to render while it loads — a fallback:
<Suspense
fallback={
<div className="text-center py-5">
<Spinner animation="border" variant="danger" role="status">
<span className="visually-hidden">Loading…</span>
</Spinner>
</div>
}
>And the effect reaches further than the pages themselves. From
post 6: because the Redux provider is
mounted inside the lazily-loaded admin layout, Redux itself ships in the admin chunk. The
99% of visitors who never open /admin pay nothing for it. Where you mount a provider
is a bundle-size decision.
The other levers, in the order they usually pay off:
| Lever | What it does |
|---|---|
| Look at the bundle first | Run a bundle visualiser. It is routinely one 300KB date or charting library nobody needed. |
| Check before you add a dependency | Cheapest possible moment to say no. |
| Images | Often more bytes than all your code. Modern formats, correct dimensions, loading="lazy" below the fold. |
| Fonts | A custom font blocks text from painting. font-display: swap, subset, and self-host. |
| Third-party scripts | Analytics and chat widgets are frequently the slowest thing on the page and nobody owns them. |
Render performance
Once the bundle is sane, the next cost is doing work on every render. The rule is unchanged from post 5: measure first with the profiler, because the component you suspect is usually not the one.
When you do have a real problem, the tools are memoising a component so identical props skip a re-render, memoising an expensive computed value, and keeping callback references stable so the first one can work at all. The demo app uses all three on its 14-card menu — and pairs them with a warning worth repeating:
Do not reach for memo by default. It costs a comparison on every render and is only worth it for components that are numerous, expensive, or both.
For genuinely long lists — thousands of rows — none of that is the answer; virtualisation is, rendering only the rows on screen. More.
The metrics that are actually used
| Metric | Measures | Usually caused by |
|---|---|---|
| LCP — Largest Contentful Paint | When the main content appears | A huge hero image, or a render-blocking bundle |
| INP — Interaction to Next Paint | How fast it responds to a tap | Long JavaScript tasks blocking the thread |
| CLS — Cumulative Layout Shift | How much the page jumps around | Images without dimensions, content injected above what you were reading |
CLS is the most fixable and the most annoying to users. Reserve the space: put
width and height on images, and make skeleton placeholders the same size
as the content they will become. A spinner that is replaced by a taller block moves the button
someone was aiming at.
Part two: accessibility
Roughly one in five people has a disability. Beyond that, the same work helps everyone with a cracked screen, one hand full, bright sunlight, or a trackpad that has died — and it is what makes your app testable and searchable at the same time.
Most of it is markup you were already writing
Everything in post 3 about
semantic elements is the accessibility work. A real <button> is
focusable, keyboard-operable and announced correctly with no extra code. A
<div onClick> is none of those and takes four additions to fake.
The rule that follows: use the right element, and reach for ARIA only when there isn't one. Bad ARIA is worse than none, because it overrides what the browser already knew.
The things that come up constantly
Every input needs a label. Not placeholder text — that disappears when you type and is usually too low-contrast to read. A real label, associated by id:
<Form.Label htmlFor={`${formId}-email`}>Email</Form.Label>Everything must work from the keyboard. Tab through your feature. Can you reach everything? Can you see where you are? Can you escape a modal? Does focus move somewhere sensible when a dialog opens and back where it came from when it closes? Never remove the focus outline without replacing it with something at least as visible.
Announce what is only visible. A spinner conveys nothing to a screen reader unless it says something. The pattern the demo app uses everywhere — a role plus text that is visually hidden but read aloud:
<span className="visually-hidden">Checking your session…</span>The mirror image also matters: content that is only decorative should be hidden from
assistive technology rather than read out. The app's emoji product thumbnail is marked
aria-hidden="true", because "pizza slice emoji" adds nothing to a card that already
says what it is.
Contrast. Aim for 4.5:1 on body text. Grey-on-white placeholder text is the single most common failure, and any browser's accessibility panel will flag it in seconds.
Do not rely on colour alone. A red border with no message says nothing to someone who cannot distinguish it. Add text or an icon.
Headings in order. They are a navigable outline, not font sizes. One
h1, no skipped levels. If you need smaller text, style it.
Respect reduced motion. Some people get motion sick from animation, and the OS has a setting saying so. The demo app wraps its transitions in a mixin for exactly this:
@media (prefers-reduced-motion: no-preference) {
/* animations go here — absent for anyone who asked for less motion */
}Four checks that catch most of it
- Unplug the mouse and use the feature. This finds more than any tool.
- Run an automated audit — Lighthouse, or axe. Catches contrast, missing labels, bad ARIA. It finds maybe a third of real issues, but it is free and instant.
- Zoom to 200%. Does anything overlap or get cut off?
- Turn a screen reader on for five minutes. VoiceOver on macOS, NVDA on Windows. Uncomfortable at first and more educational than any article.
How the two halves meet
They are not merely adjacent, they compound:
- Semantic HTML is smaller than a pile of divs and ARIA attributes — better on both counts.
- Layout shift is a performance metric and an accessibility failure, worse for anyone with a motor impairment aiming at a moving target.
- A page that works before JavaScript loads works for slow connections and for assistive technology at once.
- The roles and labels that make a screen reader work are exactly what post 11 queries by. Accessible markup is testable markup.
The one thing to take from this post
Both of these are cheap while you are writing the code and expensive afterwards. Throttle your browser once a week, tab through what you just built, and ask what a new dependency costs before you install it. That is most of the benefit for very little ceremony — and unlike almost everything else on your backlog, nobody will ever file the bug that tells you it was needed.
Next: Testing.