Most JavaScript developers write code that works without really understanding how JavaScript runs. You know callbacks execute, promises resolve, and async/await lets you avoid callback hell—but what's actually happening under the hood? Understanding the engine, call stack, heap, and event loop transforms you from someone who writes code that happens to work into someone who debugs performance issues, avoids race conditions, and designs systems that scale.
The Runtime: The Complete Picture
When you run JavaScript, you're not just executing code linearly from top to bottom. Instead, several components work together in a coordinated dance. Let me walk you through what actually happens.
First, the engine (V8 in Chrome and Node.js, SpiderMonkey in Firefox) parses your code and compiles it into machine instructions. Then the call stack executes synchronous functions one at a time. Objects and variables live in the heap, a memory region managed separately from the stack. When you make async calls—timers, network requests, file I/O—the Web APIs (in browsers) or libuv (in Node.js) handle them outside the engine. Finally, the event loop watches for completed async work and schedules callbacks back onto the call stack.
Here's a concrete example showing all these pieces interacting:
console.log('Start');
setTimeout(() => {
console.log('Timeout callback');
}, 100);
fetch('https://api.example.com/data')
.then(res => res.json())
.then(data => console.log('Data:', data));
console.log('End');
Execution order:
- Engine executes
console.log('Start')on the call stack → prints immediately - Engine encounters
setTimeout→ passes it to the Web API (not the call stack) - Engine encounters
fetch→ passes it to the Web API - Engine executes
console.log('End')on the call stack → prints immediately - Call stack is now empty
- After 100ms, the timeout callback is ready; after the network response arrives, the promise callbacks are ready
- Event loop sees the call stack is empty and pushes the next callback onto it
Output: Start, then End, then eventually Timeout callback and Data: ....
Single-Threaded Execution: The Call Stack
JavaScript has one call stack. This is both its greatest strength (simplicity, no race conditions) and its biggest weakness (blocking). When a function runs, it occupies the stack. While it's running, nothing else can run.
Consider this scenario from a real e-commerce system I built:
function calculateCartTotal(items: CartItem[]): number {
let total = 0;
// Simulating expensive computation
for (let i = 0; i < 100_000_000; i++) {
total += items[i % items.length].price;
}
return total;
}
function handleAddToCart(item: CartItem) {
const newTotal = calculateCartTotal(items);
updateUI(newTotal);
}
If this calculation takes 2 seconds, the UI thread is blocked for 2 seconds. Users can't click buttons, scroll, type into inputs—nothing. The browser itself feels frozen because the call stack is occupied and the event loop can't process user interactions.
This is why long-running tasks need to be broken up or offloaded. In production systems, I've handled this by:
- Breaking work into smaller chunks that yield control back:
setTimeout(() => processNextChunk(), 0) - Using Web Workers (in browsers) or worker threads (in Node.js) for CPU-intensive work
- Deferring non-critical updates and using techniques like debouncing and request animation frame
The call stack's single-threaded nature is not a limitation of JavaScript itself—it's a deliberate design choice that eliminates entire categories of concurrency bugs.
Memory Management: The Heap
While the call stack stores primitive values and function references, the heap is where objects, arrays, and strings live. The heap is dynamically allocated and managed by garbage collection.
let primitiveNumber = 42; // Stored on stack
let objectReference = { name: 'Aj' }; // Reference on stack, object in heap
let arrayData = [1, 2, 3, 4]; // Reference on stack, array in heap
In my trading platform work, understanding the heap became critical. We had thousands of price updates per second coming through WebSockets. Creating a new object for every update would exhaust memory quickly.
// ❌ Memory pressure: creates 1000s of objects per second
function handlePriceUpdate(symbol: string, price: number) {
const update = { symbol, price, timestamp: Date.now() };
broadcasts.push(update); // Heap fills up
}
// ✅ Better: reuse object pool
class PriceUpdatePool {
private pool: Array<{symbol: string; price: number; timestamp: number}> = [];
getUpdate(symbol: string, price: number) {
const obj = this.pool.pop() || {};
obj.symbol = symbol;
obj.price = price;
obj.timestamp = Date.now();
return obj;
}
release(obj: any) {
this.pool.push(obj);
}
}
The garbage collector runs automatically, but understanding that the heap stores objects helps you avoid memory leaks (forgotten event listeners, circular references, detached DOM nodes).
Async Is the Environment, Not the Language
Here's a key realization: JavaScript itself is synchronous. The environment (browser or Node.js) provides asynchronous capabilities. Timers, network requests, and file I/O don't happen in the engine—they happen in the environment's APIs.
In Node.js, libuv is the library that provides this. It manages thread pools for file I/O, DNS lookups, and other blocking operations. In the browser, the Web APIs (fetch, setTimeout, events) offload work to the browser's internal threads.
// JavaScript engine: synchronous
// libuv/Web APIs: asynchronous
console.log('1');
fs.readFile('./data.json', 'utf-8', (err, data) => {
// This callback runs later, after file I/O completes
console.log('3');
});
console.log('2');
// Output: 1, 2, 3
When fs.readFile is called, Node.js immediately returns and adds the task to libuv's thread pool. The JavaScript engine continues. Once the file is read, libuv signals the event loop that the callback is ready.
This separation is why Node.js can handle thousands of concurrent connections with a single thread—I/O doesn't block the JavaScript thread.
The Event Loop: Orchestrating It All
The event loop is a loop that repeatedly checks: "Is the call stack empty? If yes, are there any callbacks ready to run?" If both are true, it pushes the next callback onto the call stack.
The event loop in Node.js runs in phases:
| Phase | What runs | Example |
|---|---|---|
| timers | setTimeout, setInterval callbacks | Your 100ms timeout |
| pending callbacks | I/O error callbacks | Network errors |
| idle, prepare | Internal only | — |
| poll | I/O callbacks (fs, net) | File read, socket events |
| check | setImmediate callbacks | Your immediate() call |
| close callbacks | Socket close, stream end | Connection cleanup |
In browsers, the model is simpler: macrotasks (timers, events), then all microtasks (promises, queueMicrotask), then rendering, repeat.
console.log('Script start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve()
.then(() => console.log('Promise 1'))
.then(() => console.log('Promise 2'));
console.log('Script end');
// Output:
// Script start
// Script end
// Promise 1
// Promise 2
// setTimeout
Promises execute in the microtask queue, which runs before the next timer. This is critical knowledge for performance optimization and avoiding race conditions.
JIT Compilation: Optimizing Hot Code
Modern engines like V8 don't just interpret code—they compile it. Initially, code is interpreted. But if a function is called many times (it's "hot"), the engine's JIT (Just-In-Time) compiler generates optimized machine code based on the types it has observed.
function add(a: any, b: any) {
return a + b;
}
// First calls: interpreted, handles any type
add(5, 10); // 15
add("hello", " world"); // "hello world"
add(3.14, 2.86); // 6.0
// After many calls with consistent types:
// V8 optimizes assuming a and b are always numbers
add(100, 200); // Compiled code: very fast
This is why TypeScript is valuable in performance-critical code. By specifying types, you give the engine confidence to optimize. It's also why polymorphic code (functions that handle wildly different types) can be slow—the engine can't optimize as effectively.
In my CRM platform, I noticed that sorting large arrays of users was slow until I ensured the comparator function had consistent type handling. The engine could then optimize aggressively.
Common Mistakes
- Blocking the event loop: Running long synchronous operations prevents the event loop from processing other callbacks. Always measure and break up work.
- Forgetting the call stack is single-threaded: Race conditions aren't impossible in JavaScript (Web Workers can cause them), but the default single-threaded model makes them rare. Don't assume you have parallelism.
- Misunderstanding microtasks vs. macrotasks: Promise callbacks run before timers. If you expect setTimeout to run first, you'll debug for hours.
- Memory leaks from retained references: Callbacks that capture large objects can keep those objects in the heap. Clean up listeners and use weak references where appropriate.
- Assuming async/await magically solves performance: It makes code readable, but await still blocks. A slow I/O operation is still slow, whether you use callbacks or async/await.
Key Takeaways
- JavaScript runs through an engine (parser, compiler), call stack (executes functions), heap (stores objects), and event loop (orchestrates async callbacks).
- The call stack is single-threaded—long tasks block user interactions and other callbacks.
- Async operations (timers, I/O, network) happen in the environment (Web APIs, libuv), not the engine. Callbacks rejoin the call stack through the event loop.
- The event loop runs in phases (timers, I/O, setImmediate, etc.). Microtasks (promises) run before macrotasks (setTimeout).
- V8's JIT compiler optimizes hot code based on observed types. Stable types enable aggressive optimization; polymorphic code stays slow.
- Understanding these mechanics helps you debug performance issues, avoid blocking, manage memory, and design systems that handle load.






Comments
Loading comments…