Home / Articles / Frontend Performance: From Code Review Blind Spot to Product Metric

This article is published in English.

Frontend Performance: From Code Review Blind Spot to Product Metric

Learn why passing code review isn't enough, which Core Web Vitals actually matter, and how to measure and fix real-world React performance issues.

2316 words

It Passed Code Review

Your pull request is clean. The logic checks out. Every test passes. A senior engineer signed off on it.

You ship the change on a Thursday afternoon.

Friday morning, a message lands from the product manager: "People are saying the app feels sluggish."

So you fire up Chrome DevTools. And on your MacBook Pro, connected to home fiber, running Chrome with a dozen extensions turned off, everything looks smooth.

But your users don't share your setup.

Many of them are on a mid-range Android device from a few years back, riding a 4G connection that frequently drops to 3G. They might be in Jakarta, Lagos, or Baku — places where network latency alone can tack on 200 to 400 milliseconds to every single request.

Your app is making them sit and wait.

Why This Gap Exists

Most frontend developers build under near-perfect conditions and ship into far messier ones. That mismatch is exactly where performance issues take root.

Here's what your everyday dev environment conceals from you:

CPU throttling. Your development machine has plenty of processing power. Chrome DevTools can simulate a 4x or 6x CPU slowdown, but hardly anyone bothers to enable it.

Network conditions. Testing against localhost means zero latency. Actual users deal with round-trip times anywhere from 100 to 500 milliseconds. A data fetch that feels instantaneous on your machine can freeze the interface for a full second once it's live.

Bundle size. Adding a library while coding feels free — no visible cost. In production, though, that same dependency might tack 80KB onto your initial bundle, all of which a user on 3G has to download before anything renders.

JavaScript parse time. Getting the bundle onto the device is only step one. The browser then has to parse and run it. On weaker hardware, parsing a 500KB bundle alone can eat up 3 to 4 seconds.

Put together, these factors create a real disconnect between how your app feels to you and how it feels to the people actually using it — and that disconnect usually stays invisible until a complaint forces it into the open.

Why Performance Is a Product Decision

Frontend engineers often file performance under "technical detail." Product managers often ignore it entirely until it turns into an emergency.

Neither approach holds up.

Performance belongs in product conversations because it shapes the outcomes a business actually tracks.

Revenue. Amazon has reported that each additional 100 milliseconds of latency cost them roughly 1% in sales. At a billion dollars a day in revenue, that works out to $10 million per 100ms. Smaller companies see smaller absolute numbers, but the relationship holds.

Retention. More than half of mobile visitors — 53% — abandon a page that takes over 3 seconds to load. They rarely file a complaint. They simply leave and never return.

SEO. Google has factored Core Web Vitals into its ranking algorithm since 2021. A sluggish experience pushes your site down the results page, which means fewer people ever discover it.

Accessibility. Speed is also an equity issue. People using older devices and slower connections are disproportionately concentrated in emerging markets and lower-income groups. An app that's slow effectively locks part of your audience out.

Once you frame the conversation around revenue, retention, search visibility, and accessibility, performance stops sounding optional and starts reading as a baseline requirement.

The Metrics That Actually Matter

You can't fix what you don't measure, and you can't measure what you haven't defined. That's where a shared vocabulary for performance becomes essential.

Google's Core Web Vitals currently offer the most reliable framework for this.

LCP — Largest Contentful Paint

This tracks how long it takes for the biggest visible element on the page to render — effectively, when the user feels the page has "loaded."

Good: under 2.5 seconds. Needs improvement: between 2.5 and 4 seconds. Poor: over 4 seconds.

Typical causes: oversized, unoptimized images, resources that block rendering, and slow responses from the server.

INP — Interaction to Next Paint

This measures the delay between a user action — a click, tap, or keystroke — and the moment the screen visually responds. It replaced FID (First Input Delay) as the standard interactivity metric in 2024.

Good: under 200ms. Needs improvement: between 200 and 500ms. Poor: over 500ms.

Typical causes: heavy computation running on the main thread and synchronous work that blocks the render.

CLS — Cumulative Layout Shift

This captures how much content unexpectedly moves around while the page is loading. Scores run from 0 (nothing shifts) upward, with anything above 1 considered severe.

Good: under 0.1. Needs improvement: between 0.1 and 0.25. Poor: over 0.25.

Typical causes: images missing explicit width and height, content injected dynamically after load, and web fonts that swap in late.

How to Measure: Your Toolkit

Lighthouse (start here)

Open Chrome DevTools, switch to the Lighthouse tab, and run an audit on a mobile profile with throttling turned on.

Lighthouse returns a score between 0 and 100 for Performance, Accessibility, SEO, and Best Practices. What makes it genuinely useful is that it explains why you got that score and points you toward what to fix first.

# Or run it from the CLI for CI/CD integration
npm install -g lighthouse
lighthouse https://yourapp.com --output html --output-path report.html

Important: run Lighthouse in an incognito window every time. Installed browser extensions can skew the results.

React DevTools Profiler

This is arguably the least-used tool available to React developers, despite being one of the most revealing.

To use it, open React DevTools, switch to the Profiler tab, hit Record, interact with your application, then stop recording.

The result is a flame graph showing every render that happened — which components fired, what triggered them, and how long each one took.

What to look for:
- Components rendering more than they should
- Renders triggered by unrelated state changes
- Expensive components re-rendering on every keystroke

Web Vitals library

If you want to capture how your app performs for actual visitors rather than in a local DevTools session, the web-vitals library is the way to do it:

import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(metric => {
  // Send to your analytics service
  console.log('LCP:', metric.value);
});onINP(metric => {
  console.log('INP:', metric.value);
});onCLS(metric => {
  console.log('CLS:', metric.value);
});

This approach gives you field data collected from genuine user sessions, not numbers generated under artificial lab conditions.

The Re-Render You Didn’t Know You Had

One of the sneakiest React performance issues doesn't announce itself. It typically looks like this:

// ❌ Problem: selecting the full user object
function Header() {
  const user = useSelector(state => state.user);
  return <div>{user.name}</div>;
}

That component will re-render any time any property inside state.user changes, regardless of whether the component actually reads that property. If the user object carries twenty fields and five of them change often, Header ends up re-rendering five times more than necessary.

// ✅ Fix: select only what you need
function Header() {
  const name = useSelector(state => state.user.name);
  return <div>{name}</div>;
}

With this change, Header only reacts to changes in name. A one-line edit, but the performance impact is real.

The same logic applies to Context:

// ❌ Problem: consuming the full context
function ThemeButton() {
  const { theme, user, notifications } = useAppContext();
  return <button className={theme}>Click</button>;
}
// ✅ Fix: split contexts by update frequency
const ThemeContext = createContext();
const UserContext = createContext();function ThemeButton() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>Click</button>;
}

Lazy Loading: Stop Sending Code Users Don’t Need

A frequent misstep in React projects is shipping the whole application bundle on the very first page load — including code for routes the visitor hasn't opened and might never open.

// ❌ Problem: all routes loaded upfront
import CheckoutPage from './pages/CheckoutPage';
import AdminDashboard from './pages/AdminDashboard';
import SettingsPage from './pages/SettingsPage';
function App() {
  return (
    <Routes>
      <Route path="/checkout" element={<CheckoutPage />} />
      <Route path="/admin" element={<AdminDashboard />} />
      <Route path="/settings" element={<SettingsPage />} />
    </Routes>
  );
}
// ✅ Fix: lazy load each route
import { lazy, Suspense } from 'react';
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/checkout" element={<CheckoutPage />} />
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/settings" element={<SettingsPage />} />
      </Routes>
    </Suspense>
  );
}

With this setup, each route becomes its own chunk, so users only fetch the code needed for the page they're actually viewing. In larger apps, this change alone can shrink the initial bundle by 40 to 60 percent.

When NOT to Optimize: The useMemo Trap

Most performance guides skip this part: optimizing too early can actively hurt your code.

useMemo and useCallback come with their own cost — allocating memory and comparing dependencies on every render. Reach for them in the wrong place and you can end up slower than before.

// ❌ Unnecessary — this calculation is not expensive
function UserCard({ user }) {
  const displayName = useMemo(
    () => `${user.firstName} ${user.lastName}`,
    [user.firstName, user.lastName]
  );
  return <div>{displayName}</div>;
}
// ✅ Just compute it — string concatenation is instant
function UserCard({ user }) {
  const displayName = `${user.firstName} ${user.lastName}`;
  return <div>{displayName}</div>;
}
// ✅ useMemo IS worth it here — genuinely expensive calculation
function DataGrid({ rows, filters }) {
  const filteredRows = useMemo(
    () => rows.filter(row => matchesAllFilters(row, filters)),
    [rows, filters]
  );
  return <Table rows={filteredRows} />;
}

The guiding principle: profile before you touch anything, optimize only after that, then measure again to confirm the fix helped. Don't rely on intuition.

If the Profiler never flags a component as a bottleneck, leave it unmemoized — the added complexity isn't worth paying for.

Image Optimization: The Low-Hanging Fruit

Images are frequently the heaviest contributor to sluggish page loads, yet they're also among the simplest problems to fix.

// ❌ Unoptimized: full-size image, no lazy loading
<img src="/hero-image.png" />
// ✅ Optimized: modern format, explicit dimensions, lazy loading
<img
  src="/hero-image.webp"
  width={1200}
  height={600}
  loading="lazy"
  decoding="async"
  alt="Hero image"
/>

A few habits make a real difference:

Switch from PNG or JPEG to WebP or AVIF. WebP typically shrinks file size by 25 to 35 percent compared to JPEG at similar quality. AVIF compresses even further, though browser support for it isn't as universal yet.

Always declare explicit width and height attributes. Doing so stops the browser from shifting layout around once the image finishes loading, which directly helps your CLS score.

Apply loading="lazy" to anything below the fold. The browser then waits to fetch that image until the user is about to scroll it into view.

Add decoding="async" for images that aren't critical to the first paint. This lets the browser decode the image off the main thread instead of blocking rendering.

The Real Trade-offs

No performance technique is free. Here's an honest breakdown of what you're trading away:

Splitting code by route shrinks the initial bundle but adds a small delay the first time a user navigates to a new route. Lazy-loading images speeds up the initial load but means images visibly pop in as the user scrolls. Wrapping expensive calculations in useMemo cuts down re-renders at the cost of extra code complexity and reduced readability. Caching through a Service Worker makes repeat visits nearly instant but introduces tricky cache-invalidation logic. Server-side rendering or static generation gives you fast first paint and better SEO, but it demands more server infrastructure and adds hydration complexity.

The principle underlying all of this: never optimize for a metric you haven't actually measured.

A Lighthouse score of 95 says nothing about whether real users are having a good experience. Instrument your app with the Web Vitals library, gather data from actual visitors, locate the genuine bottleneck, fix that specific thing, and then measure again to confirm it worked.

A Practical Checklist

Before you ship any substantial feature, work through this list:

Performance Checklist
─────────────────────
□ Run Lighthouse on mobile with throttling (target score: 90+)
□ Check bundle size with webpack-bundle-analyzer or source-map-explorer
□ Verify all routes are lazy loaded
□ Confirm images use WebP/AVIF with explicit dimensions
□ Profile with React DevTools — no unnecessary re-renders
□ Check Core Web Vitals in production with web-vitals library
□ Test on a real mobile device, not just DevTools emulation
# Install bundle analyzer
npm install --save-dev webpack-bundle-analyzer
# Or for Vite
npm install --save-dev rollup-plugin-visualize

Conclusion

Performance work isn't a final push you squeeze in before launch, and it isn't a layer you bolt on once a feature already works. It's a discipline — a collection of habits and tools woven into your everyday workflow.

The engineers producing the fastest applications aren't inherently more talented than those producing slow ones. They simply measure more consistently. They know which tool fits which problem, and they've internalized a simple loop: profile first, fix only what the data points to, then measure again to confirm it helped.

An app can sail through every code review and still leave real users frustrated.

Measure. Profile. Fix what actually matters.