Skip to main content
frontendDecember 21, 20258 min read

5 Performance Mistakes That Kill Your React App

Most React apps are slower than they need to be. Here are the 5 most common performance mistakes I see in production codebases — and how to fix each one.

reactperformancenextjs
5 Performance Mistakes That Kill Your React App

I have reviewed hundreds of React codebases over the years. Client projects, open-source repos, internal tools, startup MVPs. The same performance problems show up again and again. Not exotic edge cases — basic, preventable mistakes that compound until the app feels sluggish and users start leaving.

Here are the five I see most often, with real fixes you can apply today.

1. Unnecessary Re-renders from Missing Memoization

This is the single most common performance problem in React apps. A parent component re-renders, and every child re-renders with it — even if their props have not changed. Multiply this across a deep component tree with dozens of items in a list, and you have an app that stutters on every keystroke.

The problem:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = calculateStats(user.transactions);

  return (
    <div>
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <StatsPanel stats={stats} />
      <TransactionList transactions={user.transactions} />
    </div>
  );
}

Every time searchQuery changes, calculateStats runs again — even though user.transactions has not changed. StatsPanel and TransactionList also re-render for no reason.

The fix:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = useMemo(
    () => calculateStats(user.transactions),
    [user.transactions]
  );

  const handleSearchChange = useCallback(
    (value: string) => setSearchQuery(value),
    []
  );

  return (
    <div>
      <SearchBar value={searchQuery} onChange={handleSearchChange} />
      <MemoizedStatsPanel stats={stats} />
      <MemoizedTransactionList transactions={user.transactions} />
    </div>
  );
}

const MemoizedStatsPanel = memo(StatsPanel);
const MemoizedTransactionList = memo(TransactionList);

Three things happened here:

  1. useMemo prevents calculateStats from running on every render. It only recalculates when user.transactions actually changes.
  2. useCallback stabilizes the reference to the change handler so that SearchBar does not see a "new" function on every render.
  3. memo wraps the child components so they skip re-rendering when their props are shallowly equal.

A word of caution: do not wrap every component in memo blindly. Profile first using React DevTools Profiler. Memoization has a cost — the shallow comparison itself. Apply it where it matters: expensive computations, large lists, and components that re-render frequently with unchanged props.

2. Bloated Bundles from Zero Code Splitting

I regularly see React apps that ship everything in a single JavaScript bundle. The admin dashboard, the settings page, the rarely-visited help center — all loaded upfront on the first page visit. The user wanted to see a landing page, and their browser just downloaded 2 MB of JavaScript it does not need yet.

The problem:

import AdminDashboard from "./pages/AdminDashboard";
import Settings from "./pages/Settings";
import HelpCenter from "./pages/HelpCenter";
import Landing from "./pages/Landing";

function App() {
  return (
    <Routes>
      <Route path="/" element={<Landing />} />
      <Route path="/admin" element={<AdminDashboard />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/help" element={<HelpCenter />} />
    </Routes>
  );
}

Static imports mean the bundler includes everything in one chunk. No matter which route the user visits, they download all of it.

The fix:

import { lazy, Suspense } from "react";

const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const HelpCenter = lazy(() => import("./pages/HelpCenter"));
import Landing from "./pages/Landing"; // Keep critical path static

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Landing />} />
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/help" element={<HelpCenter />} />
      </Routes>
    </Suspense>
  );
}

React.lazy with dynamic import() tells the bundler to split these pages into separate chunks. They are loaded on demand when the user navigates to that route. Your initial bundle stays small, and your Time to Interactive drops significantly.

If you are using Next.js, you get route-based code splitting for free with the App Router. But you still need to be intentional about component-level splitting for heavy third-party libraries:

import dynamic from "next/dynamic";

const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
  loading: () => <EditorSkeleton />,
  ssr: false,
});

This is critical for libraries like rich text editors, charting libraries, or PDF renderers that can easily add 200-500 KB to your bundle.

3. State Placed Too High in the Component Tree

React's rendering model is top-down. When a component's state changes, it re-renders — and so does its entire subtree. When you hoist state to the top of the tree "for convenience," you are telling React to re-render everything below it on every state change.

The problem:

function App() {
  const [selectedFilter, setSelectedFilter] = useState("all");
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <Layout>
      <Header />
      <Sidebar filter={selectedFilter} onFilterChange={setSelectedFilter} />
      <MainContent>
        <SearchBar query={searchQuery} onChange={setSearchQuery} />
        <ProductList filter={selectedFilter} />
        <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
      </MainContent>
      <Footer />
    </Layout>
  );
}

Typing in the search bar triggers a state update in App. This re-renders Header, Sidebar, Footer, ProductList, and Modal — none of which care about the search query.

The fix:

Push state down to where it is actually used:

function App() {
  return (
    <Layout>
      <Header />
      <SidebarWithFilter />
      <MainContent>
        <SearchSection />
        <ProductList />
      </MainContent>
      <Footer />
    </Layout>
  );
}

function SearchSection() {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <SearchResults query={searchQuery} />
    </div>
  );
}

Now typing in the search bar only re-renders SearchSection and its children. Everything else stays untouched.

The principle is simple: state should live as close as possible to the components that read it. If two sibling components need the same state, lift it to their nearest common parent — but no higher. For truly global state (auth, theme, locale), use a state management library that supports selective subscriptions, like Zustand or Jotai, so that only the components consuming a specific slice of state re-render when that slice changes.

4. Unoptimized Images in Next.js

Images are typically the heaviest assets on a web page. I have seen Next.js apps where developers use plain <img> tags with massive, uncompressed PNGs. The result: a page that loads 8 MB of images, zero lazy loading, no responsive sizing, and a Largest Contentful Paint time that makes Lighthouse weep.

The problem:

function ProjectCard({ project }) {
  return (
    <div className="card">
      <img src={`/images/${project.cover}`} alt={project.title} />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

This serves the full-resolution image at its original size, regardless of the user's viewport or device. No format conversion, no lazy loading, no responsive srcset.

The fix:

import Image from "next/image";

function ProjectCard({ project }) {
  return (
    <div className="card">
      <Image
        src={`/images/${project.cover}`}
        alt={project.title}
        width={600}
        height={400}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        placeholder="blur"
        blurDataURL={project.blurHash}
      />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

The Next.js Image component gives you several things automatically:

  • Automatic format conversion to WebP or AVIF based on browser support
  • Responsive sizing via the sizes attribute — the browser downloads only the size it needs
  • Lazy loading by default — images below the fold are not fetched until the user scrolls near them
  • Blur placeholder — shows a lightweight preview while the full image loads, eliminating layout shift

For images above the fold (hero banners, primary content), add priority to disable lazy loading and preload them:

<Image src={heroImage} alt="Hero" priority sizes="100vw" />

This one change alone can cut page weight by 60-80% and dramatically improve your Core Web Vitals scores.

5. Blocking the Main Thread with Heavy Computations

The browser's main thread handles rendering, user input, animations, and JavaScript execution — all on a single thread. When you run a heavy computation synchronously, everything else freezes. The user clicks a button and nothing happens for 500ms. Animations jank. Scroll stops responding.

The problem:

function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);

  useEffect(() => {
    // This blocks the main thread for 200-800ms
    const result = processLargeDataset(rawData); // Sorting, filtering, aggregating 50k+ rows
    setProcessedData(result);
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

processLargeDataset runs synchronously on the main thread. If it takes 400ms, the entire UI is frozen for 400ms. The user cannot scroll, type, or interact with anything.

The fix — move it to a Web Worker:

// workers/dataProcessor.ts
self.onmessage = (event: MessageEvent) => {
  const rawData = event.data;
  const result = processLargeDataset(rawData);
  self.postMessage(result);
};
function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/dataProcessor.ts", import.meta.url)
    );

    workerRef.current.onmessage = (event: MessageEvent) => {
      setProcessedData(event.data);
    };

    workerRef.current.postMessage(rawData);

    return () => {
      workerRef.current?.terminate();
    };
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

The heavy computation now runs on a separate thread. The main thread stays free to handle user interactions, animations, and rendering. The UI remains responsive the entire time.

For less extreme cases where the computation takes 50-150ms, you can break the work into smaller chunks using requestIdleCallback or scheduler.yield() instead of spinning up a full Worker. But for anything that processes large datasets, generates reports, or runs complex algorithms, Web Workers are not optional — they are necessary.

The Compound Effect

None of these mistakes will crash your app on its own. That is what makes them dangerous. Each one adds 100-300ms of delay, and users experience the sum total. A component that re-renders unnecessarily plus a bloated bundle plus unoptimized images plus a main-thread computation means your app takes 4 seconds to become interactive instead of 1.

Profile before you optimize. Use React DevTools Profiler to find re-render hotspots. Use Lighthouse and Web Vitals to measure real user impact. Use your browser's Network tab to catch oversized bundles and images. Use the Performance tab to identify long tasks blocking the main thread.

These are patterns I apply in every project I build — from restaurant platforms to medical SaaS systems. Performance is not a feature you add later. It is a quality you maintain from the first commit.

DU

Danil Ulmashev

Full Stack Developer

Need a senior developer to build something like this for your business?