JavaScript Performance Deep Dive: Event Loop, Memory Leaks, Closures and Async Architecture
To write high-performance JavaScript, you need to think like the engine executing your code. V8 doesn't just interpret your code top-to-bottom — it compiles, optimizes, and manages memory in ways that directly affect the behavior of your production systems.
Thinking Like the JavaScript Engine
To write genuinely high-performance JavaScript, you need to think like the engine executing your code. V8, SpiderMonkey, and JavaScriptCore do not just interpret code top-to-bottom — they compile, optimize, and manage memory in ways that directly affect the behavior of your production systems.
This article is for engineers who already know JavaScript and want to understand why it behaves the way it does under load, and how to design systems that work with the engine rather than against it.
1. The Event Loop: The Actual Execution Model
The event loop is the mechanism that allows JavaScript to be non-blocking despite running on a single thread. Understanding it precisely is what separates engineers who debug async issues quickly from those who spend hours guessing.
The execution order is deterministic:
- All synchronous code in the current call stack runs to completion
- The entire Microtask Queue is drained (all pending microtasks run)
- One task from the Macrotask Queue (Task Queue) is picked up
- The microtask queue is drained again before the next macrotask
- The browser renders (if applicable) between macrotask cycles
Microtasks vs Macrotasks
Microtasks run before the next macrotask — and before the browser gets a chance to render. This makes them higher priority but also dangerous if they produce an infinite chain.
- Microtasks:
Promise.then,Promise.catch,Promise.finally,queueMicrotask,MutationObserver,async/awaitcontinuations - Macrotasks:
setTimeout,setInterval,setImmediate(Node.js), I/O callbacks, UI events
console.log('1 — sync');
setTimeout(() => console.log('2 — macrotask'), 0);
Promise.resolve()
.then(() => console.log('3 — microtask'))
.then(() => console.log('4 — microtask 2'));
queueMicrotask(() => console.log('5 — microtask 3'));
console.log('6 — sync');
// Output: 1, 6, 3, 4, 5, 2
The setTimeout(..., 0) does not mean "run immediately" — it means "schedule as a macrotask after the current sync code and all pending microtasks have finished". In a busy application, macrotasks can be delayed significantly.
Why This Matters for UI Performance
If you have a long synchronous operation — sorting 50,000 records, running a complex filter — it blocks the entire event loop. No user events are processed, no animations update, the UI freezes. The browser cannot render between synchronous operations.
// Bad — blocks the main thread, UI freezes
function processLargeDataset(records) {
return records
.filter(r => r.active)
.sort((a, b) => b.value - a.value)
.slice(0, 1000);
}
// Better — yield to the event loop between chunks
async function processInChunks(records, chunkSize = 500) {
const results = [];
for (let i = 0; i < records.length; i += chunkSize) {
const chunk = records.slice(i, i + chunkSize);
results.push(...chunk.filter(r => r.active));
// Yield to the event loop — lets the browser render and handle user input
await new Promise(resolve => setTimeout(resolve, 0));
}
return results.sort((a, b) => b.value - a.value);
}
2. Memory Leaks: The Most Expensive Production Bug
Memory leaks in JavaScript are insidious. They do not crash your application immediately — they slow it down gradually until users start complaining about sluggish interfaces and you are staring at a growing heap in Chrome DevTools.
Dangling Event Listeners
The most common source of memory leaks in React applications. When a component adds a global event listener and the component unmounts without removing it, the listener keeps a reference to the component, preventing garbage collection of the entire component subtree.
// Leak — listener is never removed, component stays in memory after unmount
useEffect(() => {
window.addEventListener('resize', handleResize);
// No cleanup!
}, []);
// Correct — cleanup function removes the listener on unmount
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);
Closures Holding Large References
Closures are one of JavaScript's most powerful features — and one of its most common sources of memory bloat. A closure captures all variables in its lexical scope. If that scope contains a reference to a large object, that object cannot be garbage collected for the lifetime of the closure.
// Leak — the closure in the interval keeps largeDataset in memory
function startPolling() {
const largeDataset = fetchAllRecords(); // 50MB of data
setInterval(() => {
const summary = summarize(largeDataset); // still references largeDataset
displaySummary(summary);
}, 5000);
}
// Fix — extract only what the closure needs
function startPolling() {
const summary = summarize(fetchAllRecords()); // large data used, then released
setInterval(() => {
displaySummary(summary); // closure only holds the small summary
}, 5000);
}
Forgotten Timers and Subscriptions
Any timer (setInterval, setTimeout) or external subscription (WebSocket, EventEmitter, store subscriptions) that is not cleaned up on component unmount will continue running and holding references.
useEffect(() => {
const intervalId = setInterval(fetchLatestData, 10_000);
const unsubscribe = store.subscribe(handleStoreUpdate);
const ws = new WebSocket(WS_URL);
ws.onmessage = handleMessage;
return () => {
clearInterval(intervalId);
unsubscribe();
ws.close();
};
}, []);
Detached DOM Nodes
DOM nodes that have been removed from the document but are still referenced in JavaScript memory are called detached nodes. They cannot be garbage collected. This typically happens when you store DOM references in a Map or object and do not clean them up when the nodes are removed.
3. Async Architecture: Promises, async/await, and Error Boundaries
async/await is syntactic sugar over Promises — every await creates a microtask continuation. Understanding this helps you design async flows that do not create unnecessary bottlenecks.
Sequential vs Parallel Async Operations
// Sequential — total time = A + B + C (unnecessary if operations are independent)
const user = await fetchUser(id);
const permissions = await fetchPermissions(id);
const preferences = await fetchPreferences(id);
// Parallel — total time = max(A, B, C) — much faster when operations are independent
const [user, permissions, preferences] = await Promise.all([
fetchUser(id),
fetchPermissions(id),
fetchPreferences(id),
]);
Promise.all vs Promise.allSettled
Promise.all rejects immediately if any promise rejects — a single failure cancels everything. Promise.allSettled waits for all promises and gives you the result of each, whether fulfilled or rejected. Use allSettled when partial success is acceptable.
// allSettled — continues even if some requests fail
const results = await Promise.allSettled([
fetchModule('accounting'),
fetchModule('hr'),
fetchModule('inventory'),
]);
const loaded = results
.filter(r => r.status === 'fulfilled')
.map(r => (r as PromiseFulfilledResult<Module>).value);
Unhandled Promise Rejections
In Node.js, unhandled promise rejections will terminate the process in recent versions. In the browser, they produce UnhandledPromiseRejection events. Always handle errors in async flows.
// Always wrap top-level async operations
async function loadDashboard() {
try {
const data = await fetchDashboardData();
renderDashboard(data);
} catch (error) {
if (error instanceof NetworkError) {
showOfflineBanner();
} else {
reportToSentry(error);
showErrorState();
}
}
}
4. Garbage Collection and Memory Pressure
V8 uses a generational garbage collector: most objects are collected in the fast "young generation" (minor GC). Objects that survive multiple GC cycles are promoted to the "old generation" where collection is slower and causes longer pauses.
Implications for production code:
- Short-lived objects are cheap — creating and discarding them in tight loops is fine
- Large long-lived objects in the old generation slow down major GC cycles and cause noticeable pauses
- Avoid mutating object shapes after creation — V8 optimizes objects with consistent shapes (hidden classes) and de-optimizes when shapes change
// V8 can optimize this — consistent shape throughout
const point = { x: 0, y: 0 };
point.x = 10;
point.y = 20;
// V8 de-optimizes this — shape changes after creation
const obj = {};
obj.name = 'value'; // shape 1
obj.count = 0; // shape 2 — different hidden class
obj.active = true; // shape 3 — another hidden class change
Common Mistakes
- Assuming
setTimeout(fn, 0)is immediate. It schedules a macrotask, which runs after all pending microtasks and the current call stack clears. - Not cleaning up effects in React. Every
useEffectthat registers external subscriptions, timers, or listeners must return a cleanup function. - Awaiting inside loops sequentially.
for...ofwithawaitinside runs iterations sequentially. UsePromise.allwith.mapfor parallel execution. - Catching errors and silently swallowing them. Empty
catchblocks hide bugs in production. Always log or report. - Using
deleteto remove object properties. It changes the object's hidden class in V8, causing de-optimization. Set the property toundefinedornullinstead if performance matters.
Production Best Practices
- Use Chrome DevTools Memory panel to take heap snapshots before and after suspected leak scenarios. Look for detached DOM nodes and objects that should have been collected.
- Use the Performance panel to identify long tasks blocking the main thread. Any task over 50ms is a Long Task and will affect INP.
- Move CPU-intensive work off the main thread using Web Workers. Parsing large JSON payloads, complex calculations, image processing — these belong in a Worker, not the main thread.
- In Node.js, use
--expose-gcwithgc()to force collection in tests to detect leaks. Use clinic.js or 0x for production profiling.
Conclusion
JavaScript performance engineering is largely about understanding and working with the runtime's constraints: the single-threaded event loop, the generational garbage collector, and the JIT compiler's optimization assumptions. Violate those constraints and your application slows down in ways that are hard to reproduce and even harder to debug.
Understand the execution model, instrument your code with real profiling tools, and design async flows that keep the event loop responsive. These skills separate engineers who write fast code from those who write fast code on a demo machine but slow code in production. See my skills for the full JavaScript and TypeScript stack I apply to production systems.
Frequently Asked Questions
What is the difference between the microtask queue and the task queue?
The microtask queue is processed completely after every task and after every synchronous script before the next macrotask begins. It has higher priority. The task queue (macrotask queue) is processed one task per event loop iteration. Microtasks include Promise continuations and queueMicrotask. Macrotasks include setTimeout, setInterval, and I/O callbacks.
How do I detect memory leaks in a JavaScript application?
Use Chrome DevTools Memory panel. Take a heap snapshot, perform the operation you suspect causes the leak, take another snapshot, and compare. Look for detached DOM nodes in the snapshot and for object counts that keep growing. The Allocation instrumentation timeline view shows allocations over time, which is useful for catching gradual leaks.
Does async/await block the event loop?
No. await suspends the current async function and returns control to the event loop. Other tasks can run while your function is waiting. However, the code before the first await and between await points runs synchronously and can block if it is computationally heavy.
When should I use Web Workers?
Use Web Workers for CPU-intensive tasks that take more than ~10ms: parsing large JSON files, complex data transformations, image processing, cryptographic operations. Workers run on a separate thread, keeping the main thread free to handle user interactions and rendering.