Say you are trying to figure out why a React app feels fine on your development machine but sluggish on a mid-range Android phone. Taps register late, scrolling stutters right after a page loads, and Lighthouse keeps flagging “Reduce JavaScript execution time” as a top opportunity. You open the Performance tab in Chrome DevTools, record a page load, and see a wall of yellow — scripting time dominating the trace while the main thread sits blocked for seconds at a stretch.

This is one of the more common bottlenecks in React applications, and it’s also one of the more misunderstood ones. Below are the questions worth working through, in the order they tend to come up during an actual diagnosis.


What Does “JavaScript Execution Time” Actually Measure?

It refers to the time the browser’s main thread spends parsing, compiling, and running your JavaScript — as opposed to time spent on network requests, layout, or painting. In a React app, this usually breaks down into a few categories: the initial bundle parsing and evaluation, the first render pass (including all the component logic that runs before anything appears), and any long-running work triggered by user interactions afterward.

The distinction matters because the fix for slow bundle parsing (reduce the bundle) is different from the fix for a slow render pass (reduce render work) which is different again from the fix for a slow interaction handler (defer or chunk the work). Treating all scripting time as one undifferentiated problem tends to lead to solutions that address the wrong bottleneck.


How Do I Find Out Which Part of My Code Is the Problem?

Start with the Chrome DevTools Performance panel rather than guessing. Record a trace of the page load, then look at the “Main” track. Long yellow blocks represent scripting; hovering over them reveals which functions are consuming the time, often down to the specific component or module.

For React specifically, the React DevTools Profiler adds another layer — it shows which components rendered during a given commit and how long each one took. Pairing the two tools gives a fairly complete picture: the Performance panel shows when the main thread was busy, and the Profiler shows which components were responsible during that window.

One habit worth adopting before touching any code: record a baseline trace first. Without one, it’s difficult to know whether a change made a real difference or whether the perceived improvement is just variance between test runs.


Is Bundle Size the Same Thing as Execution Time?

Not exactly, though the two are related. A large bundle means more bytes to download, but it also means more code for the JavaScript engine to parse and compile before any of it runs — and that parsing cost shows up as main-thread time even for code that never executes during the session. A common mistake is optimizing only for download size (via compression) while ignoring the fact that the browser still has to parse the whole thing.

This is why code splitting tends to help both metrics at once. Splitting a route-based bundle so that the checkout flow’s code doesn’t load until a user navigates to checkout means the browser has less to parse on the initial page, regardless of network speed.

import { lazy, Suspense } from 'react';

const CheckoutPage = lazy(() => import('./CheckoutPage'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <CheckoutPage />
    </Suspense>
  );
}

React.lazy combined with dynamic import() defers both the download and the parsing of that module until it’s actually needed, rather than paying that cost on every page load.


Why Does My App Feel Slow Even After the Page Has Loaded?

This usually points to expensive work happening synchronously in response to user input — typing into a search box, dragging a slider, or filtering a large list. If a keystroke handler runs a heavy computation (say, filtering 10,000 items) directly on every render, the browser can’t paint the updated character in the input field until that computation finishes, and the UI feels like it’s lagging behind the user’s typing.

The fix here isn’t usually about reducing the total amount of work — it’s about not blocking the thread while doing it. React’s useTransition hook is built for exactly this case: it lets you mark certain state updates as non-urgent, so React can interrupt them to keep more time-sensitive updates (like the input field reflecting a keystroke) responsive.

import { useState, useTransition } from 'react';

function SearchList({ items }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const [filtered, setFiltered] = useState(items);

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value); // urgent: keeps the input responsive

    startTransition(() => {
      setFiltered(items.filter(item => item.includes(value))); // deferred
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Updating</span>}
      <ul>{filtered.map(item => <li key={item}>{item}</li>)}</ul>
    </>
  );
}

The input stays snappy because React prioritizes the keystroke update over the filtering work, even though the filtering itself still takes the same amount of time overall.


Does Memoization Fix Slow Execution Time?

Sometimes, but it’s frequently reached for before the underlying problem has been identified, and it isn’t free. useMemo and useCallback prevent recomputation or re-creation of values between renders, which is useful when a component is re-rendering often with the same inputs and the computation being skipped is expensive enough to matter.

Where this goes wrong is applying memoization to cheap calculations — string formatting, small array operations — where the memoization bookkeeping costs roughly as much as just doing the work again. Profiling with React DevTools before and after a memoization change is the only reliable way to confirm it’s helping rather than adding overhead without benefit.

The clearer signal for reaching for memoization is a Profiler flame chart showing the same component re-rendering with genuinely unchanged inputs, taking a measurable slice of time on each pass. Absent that evidence, it’s worth checking whether the real issue is unnecessary re-rendering in the first place, which memoization only partially addresses.


What About Third-Party Scripts and Libraries?

Third-party code is a frequent, and frequently overlooked, source of main-thread blocking. Analytics snippets, chat widgets, and A/B testing tools often execute synchronously on load, and because they’re outside your codebase, they don’t show up when you’re only reviewing your own components.

The Performance panel trace will still catch them — look for scripting time attributed to a domain that isn’t yours. Common mitigations include loading these scripts with the async or defer attributes, delaying non-critical ones until after the page has become interactive, or, in cases where the script isn’t needed for the initial experience, loading it only after a user interaction like a scroll or click.


How Do I Handle a Genuinely Heavy Computation That Can’t Be Avoided?

Some work — parsing a large CSV client-side, running a complex data transformation, generating a chart from a large dataset — can’t be trimmed down much further and still needs to happen somewhere. In these cases, the goal shifts from “make it faster” to “make it not block the user.”

Web Workers are the standard tool here. They run JavaScript on a separate thread, so a heavy computation can proceed without freezing the UI. The tradeoff is added complexity: workers don’t have access to the DOM, and communication with the main thread happens through message passing rather than shared state.

// worker.js
self.onmessage = function (e) {
  const result = processLargeDataset(e.data);
  self.postMessage(result);
};

// In your component
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.postMessage(largeDataset);
worker.onmessage = (e) => setProcessedData(e.data);

This pattern is worth the added setup specifically when the computation is large enough and infrequent enough that the complexity trade is justified — for a calculation that finishes in a few milliseconds, a worker adds overhead without a corresponding benefit.


A Reference Table for Common Scenarios

Symptom Likely Cause Typical Fix
Slow initial load, large bundle Too much code parsed upfront Code splitting with React.lazy
Laggy typing or dragging Synchronous heavy work blocking input updates useTransition or debouncing
Component re-renders with unchanged props Missing or ineffective memoization React.memo with stable references
Long tasks from unfamiliar domains in trace Third-party scripts async/defer, delayed loading
Unavoidable heavy computation CPU-bound work on the main thread Web Workers

Where Should I Start If I Only Have Time for One Fix?

Start with the trace, not the fix. Record a Performance panel snapshot of the slowest interaction on the slowest device you can get your hands on, and look for the single longest scripting block. Whatever function sits at the top of that block is almost always the highest-leverage place to spend the next hour, and it’s often not the thing you would have guessed by reading through the codebase.

What does your Performance panel trace look like for the interaction that’s giving you trouble? Share what you’re seeing in the flame chart and I can help you narrow down which of these fixes actually applies to your situation.