Skip to main content

The Main Thread Problem

What you'll learn

  • What the browser's main thread is responsible for, nearly everything
  • Why your screen updates in ~16.6ms "frames", and what a frame budget is
  • What a "long task" is, and why one long task can freeze typing
  • How to reproduce the freeze with a slow list, and measure it
  • Why "React should just be faster" is the wrong question, and what the right one is

You've felt this bug as a user. You type into a search box and the letters appear half a second late, in little bursts. You click a tab and the page goes dead, then everything jumps at once. The app isn't broken, and your code isn't slow in any interesting way. The page is simply busy, and by the end of this chapter you'll know exactly what "busy" means, down to the millisecond.

One thread does almost everything

Jargon: "main thread". The single browser thread that runs your JavaScript, computes styles and layout, paints pixels, and processes user input. Picture one very fast worker with one to-do list, doing tasks strictly one at a time.

Here is what lands on the main thread's to-do list for your page:

  1. Run JavaScript, event handlers, timers, promises, and all of your React code.
  2. Style and layout, after JS changes something, work out where every element goes and how big it is.
  3. Paint, turn that layout into actual pixels on the screen.
  4. Handle input, key presses, clicks, scrolls, taps.

The crucial fact: these jobs cannot overlap. While the main thread is executing your JavaScript, it is not painting, and it is not answering the user. Not slowly, not at all. It finishes the current task, then looks at the list again.

The frame budget: 16.6 milliseconds

Jargon: "frame". One full pass of "update the screen". Most displays refresh 60 times per second, so the browser gets 1000 / 60 ≈ 16.6ms to produce each new image. A 120Hz display halves that to ~8.3ms.

To look smooth, every frame must fit inside its budget. And your JavaScript doesn't even get the whole 16.6ms, the browser needs a chunk of it for style, layout, and paint. A common rule of thumb: keep any single stretch of JS under ~10ms and the page holds 60fps.

Jargon: "jank". The visible stutter when a frame misses its budget and the screen keeps showing the old image a little too long. One dropped frame is invisible; a run of them feels like the page is limping.

Long tasks: when 16.6ms becomes 800ms

Jargon: "long task". Any uninterrupted stretch of main-thread work over 50ms. DevTools flags these in red because 50ms is roughly the limit for a UI to feel instant: cross it, and users notice that taps and keystrokes don't answer.

Now the consequence that matters. Input events, like your keystrokes, are also just items on the to-do list. If the main thread is 700ms into an 800ms task when you press a key, the keypress handler waits in line. The paint that would show the letter waits too. From your side, the input looks frozen.

Feel it: a list that freezes an input

Let's build the freeze on purpose. We'll render 5,000 rows and waste a fraction of a millisecond per row to stand in for real work, formatting dates, highlighting code, computing values. Total: roughly 800ms of honest, necessary work.

import { useState } from 'react';

function burnCpu(milliseconds) {
  const start = performance.now();
  while (performance.now() - start < milliseconds) {
    // Pretend this row is expensive: highlighting, charts, math…
  }
}

function SlowList() {
  const start = performance.now();
  const rows = [];
  for (let i = 0; i < 5000; i++) {
    burnCpu(0.16); // 5000 × 0.16ms ≈ 800ms of real work
    rows.push(<li key={i}>Row {i}</li>);
  }
  console.log('SlowList render took', Math.round(performance.now() - start), 'ms');
  return <ul>{rows}</ul>;
}

export default function App() {
  const [text, setText] = useState('');

  return (
    <div>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Type fast and watch…"
      />
      <SlowList />
    </div>
  );
}

What happens:

  1. You press a key. The browser queues an input event on the main thread.
  2. The handler runs: setText schedules a re-render of App.
  3. React re-renders App, which re-renders SlowList, all 5,000 rows, ~800ms of synchronous work.
  4. During those 800ms the main thread cannot paint, so the letter you typed doesn't appear. Press five more keys: their events simply pile up in the queue.
  5. The render finally finishes, React updates the DOM, the browser paints, and six letters appear at once, in a burst.
  6. Every keystroke repeats the cycle. Typing feels like wading through mud.

Notice what did not happen: nothing errored, no code was wrong, and the work was genuinely needed. The UI froze because 800ms of work arrived as one uninterruptible block.

Measuring the freeze

You already have one measurement, the console.log prints ~800ms per render. Here is a second: time how long a keystroke takes to become visible.

import { useState } from 'react';

function burnCpu(milliseconds) {
  const start = performance.now();
  while (performance.now() - start < milliseconds) {}
}

function SlowList() {
  const rows = [];
  for (let i = 0; i < 5000; i++) {
    burnCpu(0.16);
    rows.push(<li key={i}>Row {i}</li>);
  }
  return <ul>{rows}</ul>;
}

export default function App() {
  const [text, setText] = useState('');

  function handleChange(e) {
    const start = performance.now();
    setText(e.target.value);
    // Two rAFs ≈ wait until the browser has actually painted.
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        console.log('letter visible after', Math.round(performance.now() - start), 'ms');
      });
    });
  }

  return (
    <div>
      <input value={text} onChange={handleChange} placeholder="Type fast…" />
      <SlowList />
    </div>
  );
}

What happens: each keystroke logs how long its letter took to reach the screen. With the slow list you'll see numbers like 700–900ms instead of a healthy sub-50ms.

A third way: open DevTools → Performance, record a few seconds of typing, and look for a wide red-flagged block, the long task, with the input events and the paint squashed into the gap after it.

Why can't React "just be faster"?

Tempting thought: 800ms is slow, so shouldn't React optimize it away? Look at where the time actually goes:

  • 5,000 rows must be created as elements and diffed, real CPU work.
  • 5,000 DOM nodes must be created or updated, real browser work.
  • Layout and paint for 5,000 rows, real browser work.

No cleverness removes the work; it is genuinely required to show 5,000 rows. Faster hardware shrinks 800ms to maybe 300ms, still a freeze. So the honest question is not "how do we make this faster?" It is:

Must these 800ms happen as ONE block that nothing can interrupt?

Because if the work could be split, do 5ms, let the browser breathe and answer the user, do 5ms more, the total time would still be ~800ms, but the input would never freeze. The user would type smoothly while the list catches up behind the scenes.

That is the promise this whole part of the tutorial explores. The next chapter shows exactly how React splits the block.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: The browser paints while my JavaScript runs. Reality: one thread, one job at a time, paint and input wait for JS to finish.
  • Misconception: A frozen input means my onChange is broken. Reality: the handler is fine; it (and the paint after it) simply can't get a turn on the main thread.
  • Misconception: 60fps means my JS gets 16.6ms per frame. Reality: the browser needs much of that for layout and paint; your realistic JS share is closer to 10ms.
  • Misconception: This only happens on cheap phones. Reality: an 800ms uninterrupted block freezes a top-of-the-line laptop just the same.
  • Misconception: React's diffing makes big lists cheap. Reality: diffing avoids unnecessary work; 5,000 rows of necessary work still cost real time.

Why it works this way

  • One thread keeps the platform sane. If JS and layout ran in parallel, a script could read a style while layout is mid-change, chaos. Serial execution makes everything predictable.
  • The browser cannot preempt your JS. Once a function starts, it runs to completion. Only your code, or a library like React, can choose to stop and yield.
  • Input is queued, not lost. Which is why nothing "breaks" during a freeze; it just arrives late, in bursts.
  • Responsiveness has a hard floor. ~50ms is the human "instant" threshold, which is why 50ms is the long-task line, and why the fix targets interruption points, not raw speed.

Try it yourself

  1. Run the demo and type a full sentence quickly. Expected: nothing for a moment, then letters land in bursts, roughly one burst per keystroke.
  2. Change 5,000 rows to 500. Expected: typing feels instant again. The per-row cost is identical; only the size of the uninterrupted block changed.
  3. Open DevTools Performance, record three seconds of typing, and find the red long-task blocks. Expected: each keystroke sits on top of a ~800ms task.
  4. Add a pure-CSS spinner (animation: spin 1s linear infinite) next to the input and type. Expected: the spinner keeps spinning, some animations run off the main thread, while the input still freezes. You've now seen exactly which work blocks which.

Recap

  • The main thread runs JS, layout, paint, and input handling, one job at a time.
  • A frame budget is ~16.6ms at 60fps; your JS realistically gets ~10ms of it.
  • A task over 50ms is a "long task": input and paint queue behind it and the UI feels frozen.
  • 5,000 rows ≈ 800ms of real work. The problem isn't speed, it's that the work is one uninterruptible block.
  • The right question: can the block be split so the browser can breathe between pieces?
  • Next up: how React does exactly that.

Next

Time slicing →