Back to blog

The performance mistakes I made on my first real app

Re-renders everywhere, 2.5MB bundles, 5000 DOM nodes. Here are the 7 performance mistakes I made and the fixes that actually worked.

February 11, 202614 min read
ReactPerformanceReact.memouseMemoLazy Loading
The performance mistakes I made on my first real app
The Re-render Cascade - Before and After React.memo
The Re-render Cascade - Before and After React.memo

You know what hurts more than a bug in prod? An app that works... but that nobody wants to use because it's too slow.

That's exactly what happened to me on my first real project.

The code looked fine. It worked in development. But in production, with real data and real users, everything felt sluggish. Users were complaining.

It took me weeks to understand what was happening. Here are the mistakes I made and what I learned fixing them.


Mistake 1: re-renders everywhere

I didn't understand how React re-renders work.

Every time a parent component re-renders, all its children re-render too. Even if their props didn't change. I had a dashboard with 50+ components, and clicking a single button would trigger hundreds of re-renders.

function Dashboard() {
  const [selectedTab, setSelectedTab] = useState('overview');

  return (
    <div>
      <Tabs selected={selectedTab} onChange={setSelectedTab} />
      <Header />           {/* Re-renders on every tab change */}
      <Sidebar />          {/* Re-renders on every tab change */}
      <Chart data={data} /> {/* Re-renders on every tab change */}
      <Table data={data} /> {/* Re-renders on every tab change */}
      <Footer />           {/* Re-renders on every tab change */}
    </div>
  );
}

Every tab click re-rendered everything. The Chart component was expensive. The Table had 500 rows. My users felt every single re-render.

The fix: React.memo

const Chart = memo(function Chart({ data }) {
  // Only re-renders if data changes
  return <ExpensiveChart data={data} />;
});

const Table = memo(function Table({ data }) {
  // Only re-renders if data changes
  return <ExpensiveTable data={data} />;
});

Now clicking tabs doesn't re-render Chart or Table because their data prop doesn't change.

But there's a catch. I learned it the hard way too.


Mistake 2: breaking memo with new object references

I wrapped everything in memo. Problem solved, right?

Wrong.

function Dashboard() {
  const [selectedTab, setSelectedTab] = useState('overview');

  // New object created on every render!
  const chartConfig = {
    showLegend: true,
    animate: false
  };

  return (
    <Chart data={data} config={chartConfig} /> {/* Still re-renders! */}
  );
}

memo does shallow comparison. Every render creates a new chartConfig object. New object = new reference = memo thinks props changed = re-render.

Same problem with functions:

function Dashboard() {
  // New function created on every render!
  const handleClick = () => {
    console.log('clicked');
  };

  return <Button onClick={handleClick} />; {/* Still re-renders! */}
}

The fix: useMemo and useCallback

function Dashboard() {
  const [selectedTab, setSelectedTab] = useState('overview');

  // Same reference between renders
  const chartConfig = useMemo(() => ({
    showLegend: true,
    animate: false
  }), []);

  // Same reference between renders
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return (
    <>
      <Chart data={data} config={chartConfig} />
      <Button onClick={handleClick} />
    </>
  );
}

Now memo actually works because the references stay stable.


Mistake 3: importing everything upfront

My bundle was 2.5MB.

Every user downloaded 2.5MB of JavaScript before seeing anything. The admin panel? Downloaded. The settings page? Downloaded. The rarely-used export feature? Downloaded.

import { AdminPanel } from './AdminPanel';
import { Settings } from './Settings';
import { ExportFeature } from './ExportFeature';
import { Dashboard } from './Dashboard';

function App() {
  return (
    <Routes>
      <Route path="/" element={<Dashboard />} />
      <Route path="/admin" element={<AdminPanel />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/export" element={<ExportFeature />} />
    </Routes>
  );
}

The fix: lazy loading

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./Dashboard'));
const AdminPanel = lazy(() => import('./AdminPanel'));
const Settings = lazy(() => import('./Settings'));
const ExportFeature = lazy(() => import('./ExportFeature'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/admin" element={<AdminPanel />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/export" element={<ExportFeature />} />
      </Routes>
    </Suspense>
  );
}

Now users only download what they need. Dashboard loads first. Admin panel loads when they navigate there.

My initial bundle went from 2.5MB to 400KB. First paint went from 4 seconds to under 1 second.


Mistake 4: rendering huge lists

I had a table with 5000 rows. I rendered all 5000.

function UserTable({ users }) {
  return (
    <table>
      <tbody>
        {users.map(user => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td>{user.email}</td>
            <td>{user.role}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

5000 DOM nodes. Scrolling was a slideshow.

The fix: virtualization

import { FixedSizeList } from 'react-window';

function UserTable({ users }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {users[index].name} - {users[index].email}
    </div>
  );

  return (
    <FixedSizeList
      height={500}
      itemCount={users.length}
      itemSize={50}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

Now only visible rows are in the DOM. 5000 items? Still only ~20 DOM nodes at a time. Scrolling is butter smooth.

Libraries that do this: react-window, react-virtualized, @tanstack/react-virtual.


Mistake 5: not using the React DevTools Profiler

I was guessing where the performance problems were. Sometimes I guessed right. Mostly I didn't.

Then I discovered the React DevTools Profiler.

It shows you:

  • Which components rendered
  • How long each render took
  • What triggered the render
  • "Wasted" renders (renders that produced no visible change)

How to use it

  1. Install React DevTools browser extension
  2. Open DevTools → Profiler tab
  3. Click "Record"
  4. Do the slow action in your app
  5. Click "Stop"
  6. See exactly which components are slow

The Profiler showed me that my Table component was taking 800ms to render. Not because it was complex, but because it was re-rendering on every keystroke in a search input.

I would have never found that by reading code.


Mistake 6: expensive calculations in render

I was filtering and sorting data inside the render:

function UserList({ users, searchTerm, sortBy }) {
  // Runs on EVERY render
  const filteredUsers = users
    .filter(u => u.name.includes(searchTerm))
    .sort((a, b) => a[sortBy].localeCompare(b[sortBy]));

  return (
    <ul>
      {filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

With 5000 users, filtering and sorting on every render was killing performance.

The fix: useMemo

function UserList({ users, searchTerm, sortBy }) {
  const filteredUsers = useMemo(() => {
    return users
      .filter(u => u.name.includes(searchTerm))
      .sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
  }, [users, searchTerm, sortBy]);

  return (
    <ul>
      {filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

Now the calculation only runs when users, searchTerm, or sortBy actually change. Not on every parent re-render.


Mistake 7: using index as key

This one bit me hard.

{items.map((item, index) => (
  <Item key={index} data={item} />
))}

Looks fine. Works fine... until you add, remove, or reorder items.

React uses keys to track which items changed. If you use index as key:

  • Delete item 0 → React thinks item 0 became item 1, item 1 became item 2...
  • Every item re-renders with wrong data
  • State gets mixed up between items

The fix: use unique IDs

{items.map(item => (
  <Item key={item.id} data={item} />
))}

Always use a stable, unique identifier. If your data doesn't have IDs, create them when the data is created, not during render.


The mental model I wish I had


The cheat sheet

ProblemSymptomFix
Unnecessary re-rendersClicking triggers lagReact.memo
New object/function refsmemo doesn't helpuseMemo / useCallback
Large bundleSlow initial loadReact.lazy + code splitting
Long listsScroll lagreact-window virtualization
Expensive calculationsLag on interactionsuseMemo
Wrong keysList items mix upUse unique IDs, not index

The lesson

Performance isn't magic. It's measurement + understanding.

I spent weeks guessing. I could have spent hours measuring.

The React DevTools Profiler would have shown me exactly where the problems were. The docs would have taught me about memo, useMemo, and lazy loading. But I thought I could figure it out myself.

The hard way taught me: measure first, optimize second, guess never.


This is part 2 of my "What I learned the hard way" series. Next up: how I structure a Next.js project after 3 years.

Continue to part 3: How I structure a Next.js project after 3 years →


Got questions? Hit me up on LinkedIn or check out more on my blog.