DNA🚀 PerformanceRuntime Performance & Main Thread Optimization
ðŸĶ–DinosaurPerformanceJavaScriptRuntime

Runtime Performance & Main Thread Optimization

The main thread is the bottleneck of every web app. Senior engineers keep it free — scheduling work, breaking up tasks, and offloading computation.

Runtime Performance & Main Thread Optimization

The browser's main thread handles everything: JavaScript execution, DOM updates, style calculations, layout, paint, event handling, and garbage collection. When you block it, nothing else happens — the UI freezes, input is ignored, and animations drop frames.

The 16ms Budget

At 60fps, each frame has ~16.67ms. Within that window:

┌─ Frame Budget: 16.67ms ────────────────────────────────────┐
│ JavaScript (event handlers, state updates) │ Style │ Layout │ Paint │ Composite │
│          â‰Ī 10ms ideal                      │  2ms  │  2ms   │ 1ms   │  < 1ms    │
└────────────────────────────────────────────────────────────────────────────────────┘

Your JavaScript gets about 10ms per frame before you start dropping frames.

Long Tasks

A long task is any task that blocks the main thread for > 50ms. Long tasks are the primary cause of poor INP scores and perceived sluggishness.

Detecting Long Tasks

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(`Long task: ${entry.duration.toFixed(1)}ms`, {
      name: entry.name,
      startTime: entry.startTime,
    });
  }
});
observer.observe({ type: 'longtask', buffered: true });

Breaking Up Long Tasks

// ❌ One long task — blocks main thread for 500ms
function processAll(items) {
  items.forEach(item => expensiveWork(item)); // 500ms total
}
 
// ✅ Chunked — yields to browser between chunks
async function processAllChunked(items, chunkSize = 50) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    chunk.forEach(item => expensiveWork(item));
 
    if (i + chunkSize < items.length) {
      await yieldToMain();
    }
  }
}
 
function yieldToMain() {
  return new Promise(resolve => {
    if ('scheduler' in window && 'yield' in scheduler) {
      scheduler.yield().then(resolve);
    } else {
      setTimeout(resolve, 0);
    }
  });
}

scheduler.yield() — The Modern Way

async function handleClick() {
  showLoadingSpinner();
 
  await scheduler.yield(); // Browser paints the spinner
 
  const data = expensiveComputation();
 
  await scheduler.yield(); // Browser can handle other events
 
  renderResults(data);
}

scheduler.yield() is purpose-built for yielding to the main thread while preserving task priority. It's the successor to the setTimeout(fn, 0) hack.

Web Workers — Offload Computation

For work that's too heavy for chunking, move it entirely off the main thread:

// main.js
const worker = new Worker('/workers/sort.js');
 
function sortLargeDataset(data) {
  return new Promise((resolve) => {
    worker.onmessage = (e) => resolve(e.data);
    worker.postMessage(data);
  });
}
 
const sorted = await sortLargeDataset(millionItems);
// workers/sort.js
self.onmessage = (e) => {
  const sorted = e.data.sort((a, b) => a.timestamp - b.timestamp);
  self.postMessage(sorted);
};

When to Use Workers vs Chunking

ApproachBest ForTrade-off
ChunkingLight processing, progressive resultsStill on main thread between chunks
Web WorkerHeavy computation (sorting, parsing, crypto)Message passing overhead, no DOM access
requestIdleCallbackNon-critical background workUnpredictable timing, may never run if busy

Debouncing & Throttling for UI Events

Debounce — Wait for Silence

function debounce(fn, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
}
 
const handleSearch = debounce((query) => {
  fetchSearchResults(query);
}, 300);
 
input.addEventListener('input', (e) => handleSearch(e.target.value));

Throttle — Rate Limit

function throttle(fn, interval) {
  let lastTime = 0;
  let timeoutId;
  return function(...args) {
    const now = Date.now();
    const remaining = interval - (now - lastTime);
 
    if (remaining <= 0) {
      clearTimeout(timeoutId);
      lastTime = now;
      fn.apply(this, args);
    } else if (!timeoutId) {
      timeoutId = setTimeout(() => {
        lastTime = Date.now();
        timeoutId = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}
 
const handleScroll = throttle(() => {
  updateScrollPosition();
}, 100);
 
window.addEventListener('scroll', handleScroll, { passive: true });

Passive Event Listeners

// ❌ Non-passive — browser waits to see if you call preventDefault()
window.addEventListener('scroll', handler);
window.addEventListener('touchmove', handler);
 
// ✅ Passive — browser knows you won't prevent default, scrolls immediately
window.addEventListener('scroll', handler, { passive: true });
window.addEventListener('touchmove', handler, { passive: true });

Passive listeners let the browser scroll immediately without waiting for your handler. This eliminates scroll jank caused by waiting for JavaScript.

Memory-Efficient Patterns

Object Pooling

For frequent allocations (particles, game entities, virtual list items):

class ObjectPool {
  #available = [];
  #factory;
 
  constructor(factory, initialSize = 10) {
    this.#factory = factory;
    for (let i = 0; i < initialSize; i++) {
      this.#available.push(factory());
    }
  }
 
  acquire() {
    return this.#available.pop() ?? this.#factory();
  }
 
  release(obj) {
    this.#available.push(obj);
  }
}
 
const particlePool = new ObjectPool(() => ({ x: 0, y: 0, vx: 0, vy: 0 }), 1000);

Avoid Allocation in Hot Loops

// ❌ Creates new object every frame
function update() {
  const velocity = { x: dx, y: dy }; // Garbage!
  applyVelocity(position, velocity);
  requestAnimationFrame(update);
}
 
// ✅ Reuse object
const velocity = { x: 0, y: 0 };
function update() {
  velocity.x = dx;
  velocity.y = dy;
  applyVelocity(position, velocity);
  requestAnimationFrame(update);
}

Virtualization

Render only visible items in long lists:

import { useVirtualizer } from '@tanstack/react-virtual';
 
function VirtualList({ items }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,
    overscan: 5,
  });
 
  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(row => (
          <div key={row.key} style={{
            position: 'absolute',
            top: 0,
            transform: `translateY(${row.start}px)`,
            height: `${row.size}px`,
          }}>
            <ListItem item={items[row.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Threshold: Virtualize when rendering 100+ items with individual DOM nodes.

Profiling Workflow

  1. Reproduce — Trigger the slow interaction
  2. Record — Chrome DevTools Performance tab
  3. Identify — Find the long task in the flame chart
  4. Analyze — What's in the long task? (JS function? Layout? Paint?)
  5. Fix — Apply the appropriate pattern (chunk, worker, debounce, virtualize)
  6. Verify — Record again, confirm improvement

Reading a Flame Chart

┌─── Long Task (120ms) ─────────────────────────────────────┐
│ ┌─── handleClick (90ms) ─────────────────────────┐        │
│ │ ┌─── sortItems (70ms) ──────────────────┐      │        │
│ │ │ ┌── compare (each <1ms) ────────────┐ │      │        │
│ │ │ │ thousands of tiny calls            │ │      │        │
│ │ │ └───────────────────────────────────┘ │      │        │
│ │ └──────────────────────────────────────┘      │        │
│ │ ┌─── renderTable (20ms) ──────┐               │        │
│ │ └─────────────────────────────┘               │        │
│ └────────────────────────────────────────────────┘        │
└────────────────────────────────────────────────────────────┘

This tells you: sortItems is the bottleneck. Move it to a Web Worker or use a chunked sort.

Interview Signal

Senior candidates demonstrate:

  1. 16ms budget awareness — Understanding the frame budget and what competes for it
  2. Long task strategies — Chunking, yielding, Web Workers, and when each applies
  3. Event optimization — Debounce, throttle, passive listeners with real reasoning
  4. Profiling skills — Reading flame charts, using Performance Observer, systematic diagnosis
  5. Practical judgment — Not optimizing everything, but knowing where the bottlenecks are