Skip to content

Why the Main Thread Is the Bottleneck

advanced16 min read

The 100ms Rule Nobody Talks About

Pop quiz. You have a function that processes data in 80ms. No layout thrashing, no memory leaks, no DOM mutations. Pure computation. Is it a performance problem?

function crunchData(records) {
  let result = 0;
  for (let i = 0; i < records.length; i++) {
    result += expensiveTransform(records[i]);
  }
  return result;
}

button.addEventListener('click', () => {
  const answer = crunchData(dataset); // 80ms of pure math
  display.textContent = answer;
});

Most developers would say "80ms is fine." But here's what actually happens: during those 80ms, the user clicks a dropdown, types in a search box, scrolls the page. Nothing responds. The browser can't process any input events because your JavaScript is monopolizing the only thread that handles both computation AND user interaction.

Google's INP (Interaction to Next Paint) metric penalizes any interaction that takes over 200ms. But users perceive lag starting at 50-100ms. Your "fast" 80ms function is silently degrading the experience every time it runs.

Mental Model

Think of the main thread as a single cashier at a grocery store. This cashier scans items (runs JavaScript), restocks shelves (does layout and paint), AND handles customer complaints (processes user input). They can only do one thing at a time. When a customer brings 200 items (your 80ms computation), everyone behind them in line waits. It doesn't matter how fast the scanning is — the bottleneck is that there's only one cashier. Web Workers are like opening additional checkout lanes that handle the scanning while the main cashier stays free for customer service.

What Actually Runs on the Main Thread

The main thread isn't just "where JavaScript runs." It's where almost everything the user cares about happens, all competing for the same single thread of execution:

  1. JavaScript execution — your code, event handlers, callbacks, microtasks
  2. Style calculation — matching CSS selectors to elements, computing final styles
  3. Layout — calculating the geometry of every element on the page
  4. Paint — filling in pixels for text, colors, images, borders, shadows
  5. Compositing setup — preparing layers for the GPU compositor
  6. Input event processing — click, scroll, keypress, pointer events
  7. Garbage collection — V8's incremental marking happens on the main thread (sweeping can be concurrent)
  8. Timer callbackssetTimeout, setInterval, requestAnimationFrame

All of this has to fit inside a ~16.6ms frame budget (at 60fps) or a ~8.3ms budget (at 120fps on modern displays). The moment your JavaScript burns through that budget, frames get dropped and input feels sluggish.

Quiz
Which of these tasks does NOT run on the browser's main thread?

The Long Task Problem

Chrome DevTools defines a "Long Task" as any task that blocks the main thread for more than 50ms. But where does 50ms come from?

It's based on the RAIL performance model:

  • Response: respond to user input within 100ms
  • Tasks on the main thread run sequentially — if a 60ms task is running when the user clicks, the click handler waits until that task finishes, then runs its own logic
  • To guarantee 100ms response time, no single task should exceed ~50ms (leaving headroom for the input processing and rendering work that follows)
// This is a Long Task — blocks main thread for ~150ms
function processAllRecords(records) {
  for (const record of records) {
    validateSchema(record);     // ~0.5ms each
    normalizeFields(record);    // ~0.3ms each
    computeDerivedValues(record); // ~0.7ms each
  }
}

processAllRecords(hundredRecords); // 100 * 1.5ms = 150ms block

The Performance tab in DevTools flags these with red corners. But the real insight is that Long Tasks compound: if two 40ms tasks run back-to-back, you get an 80ms block with no chance for input processing in between.

Quiz
A function takes 45ms to execute. According to the Long Task definition, is this a problem?

Why Not Just "Make It Faster"?

The natural response to "your code is too slow" is to optimize it. And sometimes that works. But there's a fundamental ceiling: some work is irreducibly expensive.

  • Image processing: applying a filter to a 4K image means touching 8.3 million pixels. Even at 1 nanosecond per pixel, that's 8.3ms — half your frame budget
  • Data parsing: parsing a 2MB JSON response takes 10-30ms depending on complexity
  • Cryptographic operations: hashing, encryption, signature verification are CPU-bound by design
  • Text search: searching 10,000 documents for a fuzzy match can't be done in under a few milliseconds
  • Physics/pathfinding: game logic, collision detection, A* on large grids

These aren't "unoptimized code." They're problems where the computation itself takes time, regardless of how cleverly you write it. The only solution is to move the work off the main thread.

// You can't optimize this below ~10ms for large datasets
// It's O(n) and every iteration does real work
function fuzzySearch(query, documents) {
  const results = [];
  for (const doc of documents) {
    const score = levenshteinDistance(query, doc.title);
    if (score < threshold) {
      results.push({ doc, score });
    }
  }
  return results.sort((a, b) => a.score - b.score);
}
Common Trap

A common trap is reaching for requestIdleCallback or setTimeout chunking to "fix" long tasks. These techniques break work into smaller pieces, which helps — but they don't add parallelism. The total time is the same or worse (due to scheduling overhead). If you need the result quickly, chunking means the user waits even longer. Workers let you do the work at full speed on a separate thread while the main thread stays responsive.

Quiz
What is the key difference between breaking a long task into chunks with setTimeout and offloading it to a Web Worker?

Measuring the Impact

Before you reach for Workers, measure first. Here's how to identify main-thread bottlenecks:

// Use the Long Tasks API to detect problems programmatically
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn(
        `Long Task detected: ${entry.duration.toFixed(1)}ms`,
        entry.attribution
      );
    }
  }
});
observer.observe({ type: 'longtask', buffered: true });

In Chrome DevTools:

  1. Open the Performance tab
  2. Record while interacting with your app
  3. Look for the red triangles on the Main row — those are Long Tasks
  4. Click any task to see the call tree and identify the expensive function

The performance.measure() API gives you precise timing for specific operations:

performance.mark('search-start');
const results = fuzzySearch(query, documents);
performance.mark('search-end');
performance.measure('fuzzy-search', 'search-start', 'search-end');

const measure = performance.getEntriesByName('fuzzy-search')[0];
if (measure.duration > 16) {
  console.log(`Search took ${measure.duration}ms — consider a Worker`);
}

When Workers Help vs When They Hurt

Workers aren't free. Creating a worker, serializing data, deserializing it on the other side — all of this has overhead. For small tasks, the overhead exceeds the benefit.

Workers help when:

  • Computation takes more than ~50ms on the main thread
  • The work is CPU-bound (not I/O-bound — fetch is already async)
  • Data can be transferred (not just copied) or is already in a transferable format
  • The result doesn't need to mutate the DOM directly

Workers hurt when:

  • The task takes less than ~5ms — serialization overhead dominates
  • You need to transfer large object graphs with many nested references (structured clone is expensive)
  • The work requires frequent DOM reads (you'd spend more time serializing DOM state than computing)
  • You're creating and destroying workers for one-off tasks (use a pool instead)
What developers doWhat they should do
Moving every function to a Web Worker for maximum parallelism
Worker communication has serialization overhead. For small tasks, the overhead of postMessage plus structured clone exceeds the computation time itself. You end up slower, not faster.
Profile first, only offload tasks that block the main thread for more than 50ms
Using requestIdleCallback to avoid long tasks
requestIdleCallback only runs when the browser is idle — it can be delayed indefinitely during busy periods. If the user needs the result (search results, data processing), a Worker delivers it faster because it runs in parallel.
Use requestIdleCallback for non-urgent work, but use Workers for time-sensitive computation
Assuming the main thread is only busy during your JavaScript execution
Your 30ms JavaScript plus 15ms of layout plus 5ms of GC already exceeds the 16ms frame budget. The main thread is shared with browser internals you cannot control.
Account for layout, paint, GC, and browser-internal tasks that also consume main thread time

Challenge: Identify the Bottleneck

Challenge: Main Thread Analysis

Try to solve it before peeking at the answer.

javascript
// This React component renders a filterable list of 5000 products.
// Users report that typing in the search box feels laggy.
// Identify the bottleneck and explain why a Worker would (or wouldn't) help.

function ProductList({ products }) {
const [query, setQuery] = useState('');

const filtered = products.filter(p => {
  const score = fuzzyMatch(query, p.name); // ~0.05ms per item
  return score > 0.6;
});

const sorted = filtered.sort((a, b) => {
  return fuzzyMatch(query, b.name) - fuzzyMatch(query, a.name);
});

return (
  <>
    <input onChange={e => setQuery(e.target.value)} />
    <ul>
      {sorted.map(p => <ProductCard key={p.id} product={p} />)}
    </ul>
  </>
);
}

Key Rules

Key Rules
  1. 1The main thread handles JavaScript, layout, paint, input processing, and GC — all competing for the same ~16ms frame budget.
  2. 2Long Tasks (over 50ms) block input processing and cause perceived lag. Measure with the PerformanceObserver Long Tasks API before optimizing.
  3. 3Some work is irreducibly expensive — no amount of algorithmic optimization can make image processing or fuzzy search instant. Move it off-thread.
  4. 4Workers add parallelism; setTimeout chunking does not. Chunking yields to the event loop but increases total execution time.
  5. 5Workers have overhead — serialization, deserialization, thread startup. Only offload work that takes more than ~50ms on the main thread.
1/7