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
| Approach | Best For | Trade-off |
|---|---|---|
| Chunking | Light processing, progressive results | Still on main thread between chunks |
| Web Worker | Heavy computation (sorting, parsing, crypto) | Message passing overhead, no DOM access |
requestIdleCallback | Non-critical background work | Unpredictable 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
- Reproduce â Trigger the slow interaction
- Record â Chrome DevTools Performance tab
- Identify â Find the long task in the flame chart
- Analyze â What's in the long task? (JS function? Layout? Paint?)
- Fix â Apply the appropriate pattern (chunk, worker, debounce, virtualize)
- 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:
- 16ms budget awareness â Understanding the frame budget and what competes for it
- Long task strategies â Chunking, yielding, Web Workers, and when each applies
- Event optimization â Debounce, throttle, passive listeners with real reasoning
- Profiling skills â Reading flame charts, using Performance Observer, systematic diagnosis
- Practical judgment â Not optimizing everything, but knowing where the bottlenecks are