· 5 min read
Frontend performance under real traffic
Lab scores are useful. Real users on mid-range phones and hotel Wi‑Fi are the exam.
I have watched teams celebrate a green Lighthouse score while support tickets said the admin “felt laggy.” Both can be true. Performance work that only chases lab metrics will miss the product moments that actually hurt: first useful paint of a dashboard, sorting a 10k-row table, submitting a form on a bad connection.
This is the longer field guide I use when a React / Next.js surface has to hold under real traffic.
Define “fast” as a user job
Before profiles and bundlers, I write the jobs:
- Time to first meaningful control on the primary screen
- Time to interactive for the primary action
- Time to complete a common mutation (save, approve, sync)
- Stability under repeat navigation (no jank tax)
If you cannot name the job, you will optimize the wrong thing — usually a hero animation nobody needed.
Measure where users actually are
I look at:
- Field data (CrUX / RUM) for LCP, INP, CLS on the routes that matter
- Product analytics for drop-offs after slow steps
- Session traces for a handful of real customer accounts
Synthetic tests still help for regressions. They do not replace a mid-range Android device on throttled 4G.
A dashboard that scores 95 in the lab and stutters when filters change is not fast. It is well-lit.
The critical path is a product decision
On first load I ask: what must be on screen for the user to start work? Everything else is deferred.
Typical cuts:
- Secondary widgets below the fold
- Charts that are not the first decision
- Marketing chrome on authenticated apps
- Fonts you do not need for the first paint
In Next.js terms that often means: smaller server payloads for the route, fewer client islands, and honest streaming where it helps — not sprinkling "use client" until the tree is a client monolith.
Data shape beats micro-optimizations
I have spent days useMemo-ing a tree that was slow because the API returned a 2MB JSON blob for a table that showed twenty rows. Fix the contract:
- Paginate or window large collections
- Return only columns the view needs
- Push aggregations to the server
- Avoid nested fan-out that forces the client to re-join the world
// Prefer this
type AttendanceRow = {
id: string;
name: string;
status: "present" | "absent" | "late";
};
// Not a kitchen-sink student document for every list cell
When payload size drops, “React performance” problems often evaporate.
Rendering discipline in React
A few rules that keep paying rent:
- Keep state local. Global stores that update on every keystroke will re-render continents.
- Split list items carefully. Virtualize when rows exceed what a screen can honestly show.
- Don’t block input. Expensive derived data belongs in workers or servers, not in the keystroke path.
- Memoization is a scalpel. Profile first. Memo everywhere is a smell that the dataflow is wrong.
INP (Interaction to Next Paint) is where many modern apps fail: a click that waits on a giant re-render feels broken even if the network is fine.
Loading states that preserve trust
Spinners that replace the whole page teach users that the product is unstable. I prefer:
- Skeleton layouts that match final geometry
- Partial hydration of known chrome
- Optimistic UI only when rollback is clear
- Disabled buttons that still explain what is happening
Layout shift is not only a CLS metric — it is a credibility problem. If the primary button jumps as fonts load, people misclick. That is a product bug.
Images, fonts, and the quiet tax
Authenticated products still pay for media mistakes:
- Unsized images that shove content down
- Too many font weights
- Icon packs imported as entire libraries
I set explicit dimensions, subset fonts when possible, and import icons per-use. Boring discipline, large wins.
Caching without lying
Caching should make repeat visits faster without showing stale money, permissions, or attendance counts as truth.
I separate:
- Immutable assets — aggressive cache
- User-specific data — short TTL or revalidate on focus
- Permissions — never casually stale
A fast wrong answer is worse than a slow right one in admin tools.
Network reality
Assume:
- Packets drop
- Tabs background
- Users submit twice
- Offline happens mid-mutation
Idempotent APIs, clear pending states, and retries with backoff are frontend concerns as much as backend ones. The UI must say what is in flight.
A workflow I actually run
When a surface feels slow:
- Reproduce on a real device profile
- Record the user job, not a homepage load
- Check network waterfalls and payload sizes
- Check main-thread long tasks during interaction
- Fix data and critical path first
- Only then micro-optimize components
- Re-measure field metrics after release
Teams that jump to step six ship clever diffs and unchanged tickets.
Case patterns from production
Heavy admin table: windowed list + server sort/filter + column allowlist. Perceived performance flipped before any fancy animation work.
Marketplace ops screens: WebSocket updates for a few live fields instead of refetching the universe. Payload overhead dropped hard when the API stopped sending decorative joins.
Multi-tenant dashboards: tenant-scoped queries with fixed page sizes; AI insight panels deferred until after the primary KPIs painted.
What I refuse to optimize for
- Pixel-perfect Lighthouse theater on authenticated routes nobody benchmarks
- Premature micro-frontends “for performance”
- Animations that delay primary content
Performance is empathy for the user’s time and device. Everything else is decoration.
Closing checklist
Before I call a frontend “fast enough”:
- Named jobs have budgets
- Field metrics exist for those routes
- Payloads match the view
- Loading preserves layout
- Interactions stay responsive under load
- Failure and retry are visible
- We measured after shipping, not only before
Do that consistently and the interface starts to feel inevitable — not because it is flashy, but because it never makes people wait without a reason.