React applications can be or feel slow for many reasons, from oversized JavaScript bundles to unnecessary re-renders. In this article, we explain how to identify the root causes of performance issues, as we do when conducting an audit. It explores the overall approach to app optimization, as well as practical optimization techniques that include code splitting, more efficient state management, list virtualization, parallel data fetching, and responsive UI feedback.

What Does “Slow” Actually Mean?

A slow product rarely fails with a single 100-to-0 crash. Typically, the issue stems from multiple unrelated factors that all contribute to the app feeling slow, unresponsive, and laggy.

Indicators start showing up in analytics. Bounce rates on key screens go up. More and more users abandon the app halfway through a core workflow. Then, support starts logging user complaints about “freezing” pages or buttons that don’t work on the first click. When a prospect interrupts a live demo to ask whether the dashboard always takes this long to load, you know the issue is getting out of hand.

Engineers investigate and find nothing conclusive. The metrics that look bad in aggregate can’t be reproduced on any single screen they test, and none of the recent changes can explain the trend. To fix this, the team ends up shipping its best guess, but the metrics don’t improve.

The problem here is that the word “slow” is a term describing a combination of several unrelated failures. A blank screen on first load, delayed keyboard inputs, and a dashboard that renders its panels one at a time produce the same complaint from users, yet the fix for one does nothing for the other two.

There are three questions that help narrow down the search before anyone even opens a profiler: 

  • Is the application slow to load, or slow to use?
  • Is it waiting for data, or struggling to render it?
  • Is it genuinely slow, or does it only feel slow?

Below is the sequence we work through in a React performance audit: how to distinguish these failures, and how to remedy each one.

Measure First

The first step in any performance audit is to stop guessing. Before touching a single component, you need to know which part of the experience is actually failing. Google’s Core Web Vitals give you three numbers for that, and each one maps directly to a different kind of complaint:

  • Largest Contentful Paint (LCP) measures how long it takes for the main content to appear on screen. The threshold is 2.5 seconds. This is the metric behind “the page takes forever to open”.
  • Interaction to Next Paint (INP) measures the delay between a user action and the visible response to it. The threshold is 200 milliseconds. This is the metric behind “it freezes when I click”.
  • Cumulative Layout Shift (CLS) measures how much the content jumps around while loading. The threshold is 0.1. This is the metric behind “the layout shifted at the last second, and I clicked the wrong button”.

How these scores are calculated is crucial. Google evaluates them on field data: the 75th percentile of real page visits over a rolling 28-day window, reported separately for mobile and desktop.

That is why a clean local Lighthouse run is deceptive. It is lab data: a single run on one simulated device and network, with no real user behind it. It only tells you what happens under one controlled set of conditions, not what your users actually experience.

We start with field data to establish whether a problem exists in the wild and which app segments it affects. Everything else, from Lighthouse to the React Profiler, only becomes useful once you know what you’re looking for.

Field data tells you which metric is failing and how badly, but it doesn’t tell you why. For that, you have to work backward from the symptom itself.

The Diagnostic Table

When a client comes in with a performance problem, they don’t describe it in bundle sizes or render cycles. They describe symptoms. The first job of an audit is translating those complaints into specific engineering targets.

While every application is different, most React performance issues follow predictable patterns. Here is the diagnostic map we use to find the starting point:

What the User ExperiencesWhat the Auditor Looks For
Prolonged blank screen before first renderOversized initial JavaScript bundle
Input lag and delayed click responseRedundant re-renders across a large React subtree
Dropped frames while scrolling long lists or tablesExcessive DOM node count
Fast initial load followed by staggered data renderingSequential network requests (Data Waterfalls)
Metrics within thresholds, yet users report sluggishnessBlocking updates, missing feedback and layout shifts

Each of these requires different tools and a different strategy. The five sections that follow tackle them one at a time.

Slow First Load

The Problem

In a standard single-page React application, the browser cannot draw the interface until it has downloaded, parsed, and executed the JavaScript bundle. If that bundle is too large, the user is left staring at a blank screen.

This happens when an application is built as a monolith. Every library, every modal window, every complex component is packaged into one file. This is the most common reason the LCP metric fails right at the entry point.

How to Diagnose

When conducting an audit, we start with the Network tab. If one JavaScript file dominates the first few seconds while everything else waits for it, that file is what’s holding your LCP.

Next, we assess how much of that file doesn’t need to be in the initial bundle at all. Lighthouse’s “Reduce unused JavaScript” audit estimates how much of that file never executes on the page the user opened, which tells us immediately how much there is to win before anyone starts untangling it.

To find out what is inside, we run a bundle analyzer to generate a treemap of the code, look inside the entry chunk, and ask one question of each large block: is this reachable from the first screen the user sees?

Example: a Login Form Behind 1.8 MB of JavaScript

We recently conducted an audit of an application where unauthenticated users landed on a login screen that took several seconds to appear. To render a simple form, the initial transfer was an entirely too large 1.8 MB of gzipped JavaScript.

The bundle analysis explained it. The application had 92 registered routes and zero code-splitting, so every page component sat in the entry chunk.

What shipped in the entry chunkShare of the chunkReachable from the first screen?
Charting stack31.6%No
Dashboard feature module8.2%No

Two blocks made up 40% of the download, and neither was reachable from a login form. The user was downloading the entire application, including a full charting engine, before even proving they had an account.

The Fix

The remedy for this issue is structured:

1. Route-level splitting. This removes the most weight for the least amount of risk. However, wrapping a few components in React.lazy() to do so is rarely enough. That is because if a component is still statically imported in any location reachable from the entry point, the bundler still keeps it in the entry chunk, and the split quietly fails. The hard part is in finding and untangling these paths.

2. Heavy, off-screen dependencies. Some dependencies bypass route splitting entirely because a global provider or a shared layout pulls them in. Charting engines, rich text editors, and complex modals must be deferred individually so they don’t block the first paint. 

3. Duplicates. Shipping the same library in two versions, or two libraries doing the same job, is dead weight that no amount of splitting removes. Consolidating on one version takes out both the duplicate and a large part of its dependency tree.

4. Everything else. This includes trimming a utility library, swapping a dependency for a lighter alternative, consolidating minor overlaps, etc. Measure the bundle again after the above steps 1-3 are completed – it’s possible that the remaining small improvements are not worth a sprint.

The Result

Agrofy, a Latin American B2B marketplace, improved its largest-contentful-paint time by 70% and saw load abandonment on product pages fall from 3.8% to 0.9%, which is a fourfold reduction in users who left before the page finished rendering.

Sluggish Interactions

The Problem

The application itself loads quickly, but is then unable to keep up with user inputs. Text appears half a second behind the keyboard, and a dropdown list or menu pauses before opening properly. Each delay is small, but they accumulate across a work session and become the main reason the INP metric fails.

In React, the cause is almost always the same: the interface redraws far more than it needs to. When a component’s state changes, React re-renders it and all components below it, whether or not their output changed. That rendering runs on the main thread, and while it runs, the browser can’t paint a frame or process the next keystroke. If a search field’s state lives at the page root, every keystroke re-renders the chart, the table, and the sidebar along with it.

How to Diagnose

When performing an audit, we start assessing this issue by recording the slow interaction in the React DevTools Profiler. If a small, supposedly lightweight action lights up most of the flame graph, it means the interface is doing far more work than it needs to.

To make the diagnosis conclusive, we enable “Record why each component rendered” in the Profiler settings. It labels each component with the reason it re-rendered, making it obvious which ones redrew only because their parent did.

The Fix

There are three approaches, listed in order of priority:

1. Push state down. Keep state as close as possible to where it is used. If only the input needs to know what is being typed, the state belongs in the input:

{/* every keystroke re-renders Chart, Table and Sidebar */}
function Dashboard() {
  const [query, setQuery] = useState("");

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Chart />
      <Table />
      <Sidebar />
    </>
  );
}

// the state lives inside SearchInput; its siblings never see it
function SearchInput() {
  const [query, setQuery] = useState("");
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

function Dashboard() {
  return (
    <>
      <SearchInput />
      <Chart />
      <Table />
      <Sidebar />
    </>
  );
}

2. Make shared state granular. When a value is genuinely needed in several places, Context is the usual solution, but its issue is that it re-renders every consumer whenever any part of its value changes. Instead, state libraries like Jotai or Zustand allow each component to subscribe to only the slice it uses. This removes a whole class of cascading renders with no need to touch the markup.

3. Memoize only what’s expensive. React.memo and useMemo tell React to skip work unless the inputs have changed. It is the tool teams usually reach for first, and often overuse. It belongs last because it treats the symptom, not the cause. Memoization isn’t free either: every memoized component pays for a comparison on each render, and wrapping cheap components can cost more than the re-renders it prevents. It pays off only for genuinely expensive parts of the interface, such as a chart, a large table, or a complex form.

The Result

redBus, an Indian bus ticketing platform, reduced interaction latency on its search page from roughly 900 ms to under 400 ms and measured a 7% increase in sales from responsiveness alone, with no changes made to pricing, inventory, or design.

Long Lists

The Problem

A table with a few thousand rows stutters while scrolling, even when the render function is perfectly optimized. 

The bottleneck has moved out of React. A table of 5,000 rows across eight columns is at least 40,000 DOM nodes. Even if React produces them in a single efficient pass and nothing re-renders afterward, the browser still has to calculate the geometry of every node, paint it, and hold the whole structure in memory. That happens in the browser’s layout and paint engine, which React knows nothing about.

That is why memoization has no effect here. While it reduces how often a render function runs, the nodes themselves stay exactly where they were.

How to Diagnose 

The React DevTools Profiler is no longer the right tool for the job: it shows a fast render, the team concludes React is fine, and the page keeps stuttering.

In an audit, we count nodes instead and check what those nodes cost. A scroll recorded in Chrome’s Performance panel shows it directly: long blocks of “Recalculate Style”, “Layout” and “Paint” confirm that the browser is struggling, and the panel’s Optimize DOM size insight flags the same problem.

The Fix

The first question is whether users need thousands of rows on one screen at all. Often pagination or server-side filtering solves the problem more simply, with none of the trade-offs below.

When the full table genuinely has to stay scrollable, the solution is virtualization: only the rows currently in the viewport exist in the DOM, plus a small buffer above and below. With a dedicated library like TanStack Virtual, the implementation looks like this:

const parentRef = useRef<HTMLDivElement>(null);

const virtualizer = useVirtualizer({
  count: rows.length,          // e.g., 5,000
  getScrollElement: () => parentRef.current,
  estimateSize: () => 48,      // row height in px
  overscan: 5,
});

return (
  <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
    {/* empty container sized to the full list — this is what holds the scrollbar */}
    <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
      {virtualizer.getVirtualItems().map((item) => ( // ~30 rows
        <Row
          key={item.key}
          row={rows[item.index]}
          style={{ position: "absolute", top: item.start, height: item.size }}
        />
      ))}
    </div>
  </div>
);

At any moment, only about thirty rows exist in the DOM. As the user scrolls, rows leaving the viewport are removed, and new ones take their place, so the DOM stays the same size whether the dataset holds 5,000 rows or 500,000.

What it Costs

This offers a significant improvement: around 240 nodes (thirty rows of eight cells) instead of 40,000, as well as a substantial drop in memory. However, there are also trade-offs that are equally significant and worth understanding before committing to this approach:

  • Browser search stops working. Ctrl+F only finds text that exists in the DOM, so a user looking for an order number won’t find it unless it happens to be on screen. If the business requires this, the UI needs its own search that filters the data.
  • Accessibility suffers. Unless aria-rowcount, aria-setsize, and aria-posinset are set manually, screen readers lose their bearings and report thirty rows instead of the actual five thousand rows that are there.
  • Complexity scales. Rows of varying height require dynamic measurement, which is where most implementations run into defects. Similarly, sticky headers, grouping, and expandable rows each add a layer of complexity to the app.

None of the above arguments are strictly against virtualization for a genuinely large table. The issue is in reaching for virtualization before the Performance panel shows the browser struggling.

Data Waterfalls

The Problem

The page loads quickly, but then the user has to wait until it fills in. The header appears, then a spinner starts spinning in place of the table. Once the table loads in, a second spinner starts inside the sidebar. No component is broken or individually slow – the requests are simply standing in a queue.

Every HTTP request costs a round trip, typically 100-300 ms on mobile networks, before a single byte of response arrives, regardless of how fast the server is. Paying that once is unavoidable. It becomes a problem when requests run in sequence, because the round trips add up.

How to Diagnose

When conducting an audit, the Network tab helps narrow down the issue by highlighting the shape of the waterfall. Requests stacked in a column, starting at roughly the same time, are running in parallel, which is what you want. On the other hand, requests that are arranged in a staircase shape, each one starting where the previous one ended, represent the problem at hand.

If the application uses a data-fetching library like TanStack Query, its DevTools complete the picture, highlighting what is served from cache and what is fetched again for no reason.

The Fix

The right remedy depends on why the requests are queued one after another, and there are four distinct causes for this:

1. Requests that were never dependent. This is the most common case, and the easiest to fix. Maybe the code was written linearly because that reads naturally, and throughout development nobody noticed that the second request doesn’t need the result from the first one. Maybe the culprit is an explicit flag that holds one query until another resolves, added for a reason that’s no longer relevant. These requests can be collapsed into a single parallel batch, and the fix is usually a few lines of code.

2. Requests dependent on tree depth. A parent component fetches and renders its request, and only then does its child mount and start its own. Each level of the tree adds a round trip, but the data being requested or provided does not require it. Hoisting the fetches to the route boundary removes the depth from the equation: the route knows what the screen needs before any component has rendered.

3. Requests that could have started earlier. Nothing is queued incorrectly; the queue simply begins too late. Prefetching on intent – such as when a user hovers over a link or enters a screen with a predictable next step – starts the request before they have finished making the decision on what that next step is. The round trip still costs some time, but it overlaps with the user’s reaction time instead of making them wait.

4. Requests that shouldn’t happen at all. Sometimes the problem is the sheer length of the queue, not its order. It’s possible that the same data is being refetched on every mount, or two components ask for the same resource independently because neither communicates with the other. Dedicated data-fetching libraries, such as TanStack Query, RTK Query, or Apollo for GraphQL, handle caching, deduplication, and background refresh in one place, allowing developers to remove entire requests when they are not needed rather than reordering them.

The Result

If three independent requests take around 200 ms each to complete, running them in sequence keeps the user waiting roughly 600 ms; however, they could have finished in the same 200 ms when working in parallel. When the main content of the screen depends on this data, every level of the waterfall delays LCP by at least one full request.

That is why the Network tab comes before the profiler in an audit. If the requests are arranged in a staircase, no amount of component-level work will close the gap.

Fast Enough, It Just Feels Slow

The Problem

Every metric is within threshold, yet users still say the application is slow. This is due to the gap between what the browser can observe and what a human notices.

There are three situations that produce this issue:

  • The interface is busy when the user acts. An action triggers work the browser must finish before it can paint anything. The delay is short enough to pass INP, but long enough to feel like hesitation on every interaction.
  • The action goes unacknowledged. A user clicks, and the interface waits for the server before showing anything. INP only measures how quickly the browser paints the next frame, not whether that frame shows anything meaningful. Four hundred milliseconds of nothing reads as broken to the user, so they click again, and now there are two requests in flight.
  • Content arrives and shifts the layout. Data loads in and pushes everything already on the screen downward. While each shift may be too small to fail CLS, they can still be big enough for users to notice and potentially click the wrong item on the page.

How to Diagnose

There is no metric that can flag this issue – the profiler may be clean, the Network tab may show no issues, and the field data may be within thresholds. However, if the complaints keep coming, it means there is a problem with what the users see when interacting with the app.

In an audit, this is where we turn to session replays. Certain indicators, such as double-clicks on the same control, repeated form submissions, and users abandoning a seemingly fast step, are the signatures of an interface that responded to user input in a way that wasn’t clear. Most replay tools flag these patterns automatically as rage clicks or dead clicks.

The Fix

All three fixes below work the same way: they manage what the user perceives while the code is running.

1. Separate urgent updates from the rest. useDeferredValue and useTransition let React update the control the user is touching immediately, while the expensive recalculation runs in the background and gives way if the user keeps typing.

2. Acknowledge actions before the server answers. For predictable actions such as toggles or “Like” buttons, optimistic updates are the standard. That means changing the UI immediately upon user input, and rolling back the changes only in case the request fails. For unpredictable flows, it’s possible to disable the button or show an inline spinner the moment it is pressed. This way, feedback appears exactly where and when the user is looking for it.

3. Reserve the space before the content arrives. A skeleton loader has to occupy the same space as the content it stands in for. A skeleton that doesn’t will resize on arrival and cause the very layout shift it was meant to prevent.

The Result

This is the one category where the results show up in behavior more than in benchmarks. The work prevents duplicate submissions, cuts support tickets about “freezing”, and changes the user’s perception of whether or not the product works at all.

When Tuning Isn’t Enough

The overall process is the same across different issues and remedies: identify the symptom before selecting a tool, tackle the largest issue rather than the nearest one, and confirm the results in the field. Otherwise, when a technique is used before the assessment, performance work fails.

Most engineering teams can work through this loop without external assistance. The harder part is knowing when simple tuning is no longer enough.

The clearest indicator is when fixes stop holding. Each release brings the defect back, not because of incorrect fixes, but because the underlying issue is still in place. It may be baked into the state shape, the routing boundaries, the import graph, or the way data is fetched. These are all products of architectural decisions, and each one seemed reasonable when it was made. Moreover, none of them look like a problem until the complaints start arriving.

That is what a performance audit is for. It establishes a real-world baseline, maps each symptom to its actual cause, and gives developers a prioritized action plan: what to fix today, what to defer, and what isn’t worth the engineering effort at all.

None of the techniques here are secret. The part that takes work is finding out which of them your application actually needs.

Check out our blog for more insights from SysGears engineers.

Want to know why your React application is slow? Let’s talk about a performance audit.

If you’re looking to hire React developers for your project, we can help.