← Back to Blog

Optimizing React Performance: From 3 Seconds to 300ms

Optimizing React Performance: From 3 Seconds to 300ms

I still remember the day someone called to complain that our platform was “slower than their old fax machine.” Ouch. They weren’t wrong though. The dashboard was taking 3+ seconds to load, and every interaction felt sluggish.

After two weeks of optimization work, we got that down to under 300ms. Here’s how we did it, and more importantly, what actually moved the needle versus what was just busy work.

The Wake-Up Call

Before you start optimizing, you need to know where you actually stand. I made the mistake of guessing at first. “Oh, it’s probably all these API calls” or “I bet it’s the images.” I was wrong on both counts.

Lighthouse Scores: The Before Picture

Here’s what we were dealing with:

Performance Score: 42/100
First Contentful Paint: 2.8s
Largest Contentful Paint: 4.2s
Time to Interactive: 5.1s
Total Blocking Time: 890ms

Embarrassing. But at least I had numbers to work with.

What Actually Mattered (The 80/20 of Performance)

I tried about 15 different optimizations. Three of them made 80% of the difference. Let me start with those.

1. Code Splitting (Shaved Off 1.8s)

Our bundle was massive. 847KB of JavaScript, most of which users didn’t need on the first page load. We were loading:

  • The entire search interface
  • All the forms
  • Admin panels
  • Report generation code

All for a landing page that just showed a login form and some stats.

Here’s what fixed it:

// Before: Everything imported at the top
import ProductSearch from './ProductSearch';
import CheckoutForm from './CheckoutForm';
import AdminPanel from './AdminPanel';
import ReportGenerator from './ReportGenerator';

// After: Lazy load what you need, when you need it
const ProductSearch = lazy(() => import('./ProductSearch'));
const CheckoutForm = lazy(() => import('./CheckoutForm'));
const AdminPanel = lazy(() => import('./AdminPanel'));
const ReportGenerator = lazy(() => import('./ReportGenerator'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/search" element={<ProductSearch />} />
        <Route path="/checkout" element={<CheckoutForm />} />
        {/* etc */}
      </Routes>
    </Suspense>
  );
}

Result: Initial bundle size dropped from 847KB to 234KB.

But here’s the thing nobody tells you: the first time I implemented code splitting, I did it wrong and actually made things slower. I was lazy-loading components that were needed immediately, so users saw a spinner, then the page, then another spinner. Terrible UX.

The trick is to only lazy-load routes or large features that aren’t needed on initial render. Your main layout, common components, and first-view content should load normally.

Memo-izing Expensive Renders (Shaved Off 600ms)

We had a listing component that was re-rendering on every single state change in the parent component. Even when the data hadn’t changed at all.

Here’s the culprit:

// Parent component
function Dashboard() {
  const [selectedDate, setSelectedDate] = useState(new Date());
  const [items, setItems] = useState([]);
  const [userPrefs, setUserPrefs] = useState({});

  return (
    <div>
      <DatePicker value={selectedDate} onChange={setSelectedDate} />
      <UserPreferences prefs={userPrefs} onChange={setUserPrefs} />
      <ItemList items={items} /> {/* This was re-rendering constantly */}
    </div>
  );
}

Every time the user changed the date or updated their preferences, ItemList was re-rendering even though items hadn’t changed. With 100+ items, each with images and complex logic, this was killing performance.

The fix:

const ItemList = memo(function ItemList({ items }) {
  return (
    <div className="item-list">
      {items.map(item => (
        <ItemCard key={item.id} item={item} />
      ))}
    </div>
  );
});

But wait, there’s more. Each ItemCard was also doing expensive calculations:

// This was running on every render, even when props hadn't changed
function ItemCard({ item }) {
  const calculations = calculateComplexData(
    item.baseValue,
    item.adjustments,
    item.discounts,
    item.options
  );

  // render stuff
}

Fixed with useMemo:

function ItemCard({ item }) {
  const calculations = useMemo(
    () => calculateComplexData(
      item.baseValue,
      item.adjustments,
      item.discounts,
      item.options
    ),
    [item.baseValue, item.adjustments, item.discounts, item.options]
  );

  // render stuff
}

Don’t just memo everything. I wasted a day wrapping every single component in memo and actually made things slightly worse (memo has overhead too). Profile first, optimize what matters.

Virtualizing Long Lists (Shaved Off 400ms)

The list could have 500+ items. We were rendering all of them in the DOM, even though the user could only see maybe 10 at a time.

I used react-window to fix this:

import { FixedSizeList } from 'react-window';

function ItemList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      <ItemCard item={items[index]} />
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={120}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

This was a game-changer. Instead of rendering 500 DOM nodes, we were only rendering the 6-7 visible ones plus a few for buffering.

Result: List rendering went from 450ms to under 50ms.

What Didn’t Matter Much (The Stuff I Wasted Time On)

Image Optimization (Shaved Off 50ms)

I spent half a day converting all images to WebP, setting up srcsets, and implementing lazy loading. The performance gain was… minimal. About 50ms.

Don’t get me wrong, it’s still worth doing for bandwidth savings, but it wasn’t the bottleneck I thought it was. Our JavaScript bundle was the real problem.

Switching from Create React App to Vite (Shaved Off 0ms in Production)

Dev server startup went from 8s to 300ms. Amazing for developer experience! Production bundle size? Basically the same.

Good change for team morale, but didn’t help end users at all.

Over-Aggressive Memoization (Actually Made Things Worse)

After learning about useMemo and memo, I went on a memoization spree. Every component got wrapped, every calculation got memoized. The result? The app actually got slightly slower.

Turns out, React is pretty good at knowing what to re-render. Adding memoization everywhere added overhead that wasn’t worth it for simple components.

The Profiling Tools That Actually Helped

React DevTools Profiler

This was my best friend. It shows you exactly which components are re-rendering and how long they take:

1. Open React DevTools
2. Go to Profiler tab
3. Click Record
4. Interact with your app
5. Stop recording
6. Look for tall bars (those are slow renders)

That’s how I found that ItemList was the problem, not some other component I suspected.

Chrome DevTools Performance Tab

For finding non-React bottlenecks:

1. Open DevTools
2. Performance tab
3. Record while loading your app
4. Look for long tasks (anything over 50ms)

This is how I discovered we were doing expensive date formatting in a loop. Moved it out, saved 200ms.

Lighthouse

Good for overall metrics and catching obvious issues. But use it as a guide, not gospel. Sometimes Lighthouse complains about things that don’t actually matter for your users.

The Measurement Approach That Works

Here’s my process now:

  1. Measure baseline with Lighthouse and real user monitoring
  2. Profile to find the actual bottleneck (not what you think it is)
  3. Fix ONE thing at a time
  4. Measure again to verify it helped
  5. Repeat

I used to try to fix everything at once. Terrible idea. You don’t know what actually helped and what was a waste of time.

The Results: Before and After

Before:

Performance Score: 42/100
First Contentful Paint: 2.8s
Largest Contentful Paint: 4.2s
Time to Interactive: 5.1s
Total Blocking Time: 890ms
Bundle Size: 847KB

After:

Performance Score: 94/100
First Contentful Paint: 0.6s
Largest Contentful Paint: 0.9s
Time to Interactive: 1.2s
Total Blocking Time: 45ms
Bundle Size: 234KB (initial)

More importantly: We stopped getting complaints. The dashboard felt snappy. Users were happy.

Lessons I Learned the Hard Way

1. Profile First, Optimize Second

I wasted so much time optimizing things that weren’t slow. Your intuition about what’s slow is probably wrong. Let the profiler tell you.

2. Don’t Optimize Too Early

We have a reporting page that takes 2 seconds to load. Know how many users complained? Zero. Because it’s running a complex report and they expect it to take a moment.

Focus on the pages users hit most often and where speed actually matters.

3. Real User Monitoring Beats Synthetic Tests

Lighthouse on my MacBook Pro showed way better scores than what our users actually experienced on their older office PCs. We added real user monitoring and got much better data.

4. Bundle Size Matters More Than You Think

I thought network requests were the bottleneck. Turns out, parsing and executing 847KB of JavaScript was the real killer. Smaller bundles = faster TTI, every time.

5. There’s Always a Trade-Off

Code splitting improved load times but added complexity. Memoization improved performance but made the code harder to reason about. Virtualization was great but broke some accessibility features we had to fix.

Perfect is the enemy of good enough.

Your Action Plan

If your React app is slow:

Hour 1 - Run Lighthouse, open React DevTools Profiler, note your baseline numbers.

Hour 2 - Find the low-hanging fruit. Is your bundle huge? Add code splitting. Long lists? Add virtualization. Expensive calculations in render? Add useMemo.

Hour 3 - Measure again. Did it actually help? If yes, ship it. If no, revert and try something else.

This Week - Add real user monitoring, watch for regressions, keep an eye on bundle size.

This Month - Fix the next biggest bottleneck. Don’t optimize things that are already fast. Always measure before and after.

The Bottom Line

We got our load time from 3 seconds to 300ms with three main changes:

  • Code splitting (saved 1.8s)
  • Memoization (saved 600ms)
  • List virtualization (saved 400ms)

Everything else was nice to have but didn’t move the needle much.

Your bottlenecks will be different from ours. That’s why profiling matters. But chances are, if your React app is slow, it’s one of these three issues.

Don’t guess. Profile. Fix. Measure. Ship.

Performance optimization is one of those skills that compounds. The patterns you learn on one project transfer directly to the next. These techniques have become part of my standard toolkit, and they’ve helped me ship consistently fast applications ever since.