JavaScript doesn’t just “run” your code instantly — it goes through a series of steps inside the engine and the host environment (browser or Node.js) before anything appears on the screen or in the console.
By understanding this journey from source code to running program, you can write code that behaves more predictably, performs better, and avoids subtle async bugs.
This guide walks you through the JavaScript processing pipeline — from the high-level compilation stages to what happens at runtime with the call stack, event loop, task queues, and heap.
1. Overview of the JavaScript Processing Pipeline
Before your code can run, the JavaScript engine transforms it step-by-step from text into something the CPU can execute.
This process — the JavaScript processing pipeline — can be broken into four main stages:
-
Lexing / Tokenizing
- The engine scans your source code and splits it into tokens — the smallest meaningful units (e.g.,
const
,sum
,=
,42
).
- The engine scans your source code and splits it into tokens — the smallest meaningful units (e.g.,
-
Parsing
- The parser converts tokens into an Abstract Syntax Tree (AST).
- If a syntax error is found, execution stops immediately with a parse-time error (
SyntaxError
).
-
Compilation
- The interpreter turns the AST into bytecode (V8 uses Ignition).
- A JIT compiler may further optimize “hot” code paths into machine code (V8 uses TurboFan).
-
Runtime execution
- The CPU executes the bytecode or machine code.
- The engine manages variables, functions, and objects in memory using the stack and heap.
2. Core Components During Runtime Execution
Once the runtime execution stage begins, your code interacts with a set of core components that manage memory, track function calls, and schedule tasks.
Heap
- The heap is a large, mostly unstructured memory area where objects, arrays, and functions are stored.
- Variables in the stack often hold references to these heap objects.
Stack (Call Stack)
- The call stack is a last-in, first-out (LIFO) structure the engine uses to keep track of which functions are currently running.
- When a function finishes, its execution context is popped off the stack.
- If the stack is empty, the event loop can dispatch new tasks.
Task Queues
- The runtime keeps callbacks waiting to be executed in different queues:
-
Macrotask queue → timers (
setTimeout
,setInterval
), I/O events, UI rendering tasks. -
Microtask queue → Promise callbacks,
queueMicrotask
,MutationObserver
.
-
Macrotask queue → timers (
Event Loop
- The event loop is JavaScript’s scheduler. It keeps your single-threaded code responsive by repeatedly: finishing what’s on the call stack, running any queued microtasks (Promise callbacks), optionally letting the browser render, then taking the next macrotask (e.g.,
setTimeout
, I/O) and doing it again.
3. Diagram of JavaScript Execution Environment
Here’s a high-level view of how the heap, stack, queues, and event loop interact, based on the MDN agent model diagram:
+------------------------------------------------------+
| JavaScript Runtime (Browser / Node.js) |
| |
| +-------------------+ +---------------------+ |
| | Heap | | Call Stack | |
| | {objects, arrays}| | main() | |
| | functions | | foo() | |
| +-------------------+ +---------------------+ |
| ^ | |
| | v |
| +-------------------+ +-----------------------+ |
| | Task Queues | | Event Loop | |
| | [Macrotasks] |<--| (checks, dispatches) | |
| | [Microtasks] | +-----------------------+ |
+------------------------------------------------------+
Example: How Functions Execute Under the Hood
function foo() {
console.log('Inside foo');
}
function bar() {
console.log('Start bar');
foo();
console.log('End bar');
}
console.log('Start');
bar();
console.log('End');
Step-by-step execution flow:
-
The engine stores the compiled
foo
andbar
function objects in the heap. - The engine creates the global execution context object and pushes it onto the call stack.
- Runs
console.log('Start')
:- Pushes the console.log(‘Start’) function call object onto the call stack.
- Executes it (prints
"Start"
). - Pops it off.
- Encounters
bar()
:- Creates a bar execution context object and pushes it onto the call stack.
- Inside
bar
, runsconsole.log('Start bar')
:- Pushes the console.log(‘Start bar’) function call object.
- Executes it (prints
"Start bar"
). - Pops it off.
- Calls
foo()
:- Creates a foo execution context object and pushes it onto the call stack.
- Inside
foo
, runsconsole.log('Inside foo')
:- Pushes the console.log(‘Inside foo’) function call object.
- Executes it (prints
"Inside foo"
). - Pops it off.
- Pops the foo execution context object off the call stack.
- Back in
bar
, runsconsole.log('End bar')
:- Pushes the console.log(‘End bar’) function call object.
- Executes it (prints
"End bar"
). - Pops it off.
- Pops the bar execution context object off the call stack.
- Back in the global context, runs
console.log('End')
:- Pushes the console.log(‘End’) function call object.
- Executes it (prints
"End"
). - Pops it off.
- Pops the global execution context object off the call stack.
- The call stack is empty → the event loop checks the microtask queue and macrotask queue for pending callbacks.
4. Single-Threaded Nature of JavaScript
JavaScript executes one task at a time in each environment (browser tab, Node.js process, or worker).
There’s only one call stack, so a long-running synchronous operation will block rendering, event handling, and async callbacks.
5. Microtasks vs Macrotasks
JavaScript schedules asynchronous work in two main buckets:
Microtasks
- Always run before moving to the next macrotask.
- Examples: Promise
.then
,queueMicrotask
.
Macrotasks
- Scheduled for execution in the main event loop cycle.
- Examples:
setTimeout
,setInterval
, network events.
Example:
setTimeout(() => console.log('Macrotask'), 0);
Promise.resolve().then(() => console.log('Microtask'));
// Output:
// Microtask
// Macrotask
6. Synchronous vs Asynchronous Execution
JavaScript can run code in two different ways: synchronously (blocking) and asynchronously (non-blocking).
Synchronous Execution (Blocking)
Synchronous code runs line-by-line, top-to-bottom.
The call stack must be fully cleared before any queued tasks can run.
Example:
console.log('A');
console.log('B');
console.log('C');
// Output: A, B, C
Under the Hood:
- Push global execution context onto stack.
- Run
console.log('A')
→ push, execute, pop. - Run
console.log('B')
→ push, execute, pop. - Run
console.log('C')
→ push, execute, pop. - Stack empty → event loop checks queues.
Blocking Example:
console.log('Start');
for (let i = 0; i < 1e9; i++) {} // Heavy computation
console.log('End');
The loop keeps the call stack busy until it finishes, blocking everything else — including UI updates.
Asynchronous Execution (Non-Blocking)
Asynchronous execution allows JavaScript to handle long-running operations without pausing the rest of the program.
Under the Hood — setTimeout
:
console.log('A');
setTimeout(() => console.log('B'), 0);
console.log('C');
output:
1. Logs `A`.
2. Calls `setTimeout`:
- Hands timer setup to the runtime’s Timer API.
- Timer runs outside the engine.
3. Logs `C`.
4. Stack empty → run microtasks (none here) → run macrotask (`B`).
Under the Hood — async/await
:
async function example() {
console.log('1');
await Promise.resolve();
console.log('2');
}
console.log('A');
example();
console.log('B');
output:
1. Logs `A`.
2. Calls `example()` → logs `1`.
3. `await Promise.resolve()` schedules continuation in microtask queue.
4. Logs `B`.
5. Stack empty → run microtask → logs `2`.
7. When to Use queueMicrotask
vs setTimeout
As I mentioned before,JavaScript schedules asynchronous work in two main ways:
- Microtasks — run right after the current task, before rendering.
- Macrotasks — run after microtasks and potentially after the browser has rendered.
queueMicrotask
adds work to the microtask queue (runs sooner),
while setTimeout
schedules work in the macrotask queue (runs later).
In short:
-
queueMicrotask
→ great for immediate follow-up logic without blocking. -
setTimeout
→ best for letting the browser update the UI or breaking up heavy tasks.
Example: Execution Order
console.log('1: Synchronous start');
queueMicrotask(() => {
console.log('2: queueMicrotask');
});
setTimeout(() => {
console.log('3: setTimeout');
}, 0);
console.log('4: Synchronous end');
Output:
1: Synchronous start
4: Synchronous end
2: queueMicrotask
3: setTimeout
Why?
-
queueMicrotask
runs after the current synchronous code, before anysetTimeout
. -
setTimeout
waits until the next macrotask phase, after all microtasks finish.
7. Avoiding Long-Running Synchronous Operations
Long-running synchronous operations are one of the biggest performance killers in JavaScript applications. Since JavaScript is single-threaded, any operation that monopolizes the call stack will freeze your entire application.
Why Long-Running Operations Are Problematic
When a synchronous operation runs for too long:
- UI becomes unresponsive - buttons don’t click, scrolling freezes
- Animations stutter - CSS animations and transitions halt
- Event handlers can’t fire - user input is ignored
-
Timers are delayed -
setTimeout
callbacks can’t execute - Network responses queue up - fetch/XHR callbacks are blocked
Identifying Blocking Operations
Common culprits include:
// ❌ BAD: Processing large arrays synchronouslyfunction processHugeArray(items) {
const results = []; for (let i = 0; i < items.length; i++) {
results.push(expensiveOperation(items[i])); }
return results;
}
// ❌ BAD: Complex calculationsfunction calculatePrimes(max) {
const primes = []; for (let n = 2; n <= max; n++) {
let isPrime = true; for (let i = 2; i <= Math.sqrt(n); i++) {
if (n % i === 0) {
isPrime = false; break; }
}
if (isPrime) primes.push(n); }
return primes;
}
Strategies to Avoid Blocking
1. Chunking with setTimeout
Break work into smaller chunks, yielding control back to the event loop between chunks:
// ✅ GOOD: Process array in chunks
async function processHugeArrayChunked(items, chunkSize = 100) {
const results = [];
for (let i = 0; i < items.length; i += chunkSize) {
// Get a slice of the array (one chunk)
const chunk = items.slice(i, i + chunkSize);
// Process each item in the chunk
chunk.forEach(item => {
results.push(expensiveOperation(item));
});
// Yield control back to the event loop (prevents UI blocking)
await new Promise(resolve => setTimeout(resolve, 0));
}
return results;
}
2. Using requestIdleCallback
Schedule non-critical work when the browser is idle:
// ✅ GOOD: Process array in chunks
async function processHugeArrayChunked(items, chunkSize = 100) {
const results = [];
for (let i = 0; i < items.length; i += chunkSize) {
// Extract a portion of the array
const chunk = items.slice(i, i + chunkSize);
// Process each item in the chunk synchronously
chunk.forEach(item => {
results.push(expensiveOperation(item));
});
// Yield control back to the event loop to avoid blocking
await new Promise(resolve => setTimeout(resolve, 0));
}
return results;
}
9. Browser vs Node.js Event Loop
Browsers follow the WHATWG HTML event loop model.
Node.js uses event loop, which has multiple phases that affect callback order.
10. Rendering and Hydration
Browsers render after microtasks are cleared at the end of a macrotask.
Hydration in frameworks attaches JavaScript behavior to pre-rendered HTML — a framework concern, not part of the core event loop.
11. Q&A
Q1: What’s the difference between a task and a job?
- In spec terms, a job is any scheduled unit of work (microtask or macrotask).
- In browser docs, “task” usually means macrotask.
Q2: When is the UI rendered?
- After the microtask queue is drained at the end of a macrotask.
Q3: Why does my Promise resolve before setTimeout
even with 0 ms delay?
- Promise callbacks are microtasks, and microtasks always run before any macrotask.
Q4: How can I prevent my application from freezing during heavy computations?
- Break work into chunks using
setTimeout
, use Web Workers for CPU-intensive tasks, or leveragerequestIdleCallback
for non-critical work.
Top comments (0)