DEV Community

kouta222
kouta222

Posted on

Behind the Scenes of JavaScript: How Your Code Actually Runs

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:

  1. Lexing / Tokenizing
    • The engine scans your source code and splits it into tokens — the smallest meaningful units (e.g., const, sum, =, 42).
  2. 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).
  3. 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).
  4. 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.

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]     |   +-----------------------+    |
+------------------------------------------------------+

Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Step-by-step execution flow:

  1. The engine stores the compiled foo and bar function objects in the heap.
  2. The engine creates the global execution context object and pushes it onto the call stack.
  3. Runs console.log('Start'):
    • Pushes the console.log(‘Start’) function call object onto the call stack.
    • Executes it (prints "Start").
    • Pops it off.
  4. Encounters bar():
    • Creates a bar execution context object and pushes it onto the call stack.
  5. Inside bar, runs console.log('Start bar'):
    • Pushes the console.log(‘Start bar’) function call object.
    • Executes it (prints "Start bar").
    • Pops it off.
  6. Calls foo():
    • Creates a foo execution context object and pushes it onto the call stack.
  7. Inside foo, runs console.log('Inside foo'):
    • Pushes the console.log(‘Inside foo’) function call object.
    • Executes it (prints "Inside foo").
    • Pops it off.
  8. Pops the foo execution context object off the call stack.
  9. Back in bar, runs console.log('End bar'):
    • Pushes the console.log(‘End bar’) function call object.
    • Executes it (prints "End bar").
    • Pops it off.
  10. Pops the bar execution context object off the call stack.
  11. Back in the global context, runs console.log('End'):
    • Pushes the console.log(‘End’) function call object.
    • Executes it (prints "End").
    • Pops it off.
  12. Pops the global execution context object off the call stack.
  13. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Under the Hood:

  1. Push global execution context onto stack.
  2. Run console.log('A') → push, execute, pop.
  3. Run console.log('B') → push, execute, pop.
  4. Run console.log('C') → push, execute, pop.
  5. Stack empty → event loop checks queues.

Blocking Example:

console.log('Start');

for (let i = 0; i < 1e9; i++) {} // Heavy computation

console.log('End');
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

output:

1. Logs `A`.
2. Calls `setTimeout`:
    - Hands timer setup to the runtimes Timer API.
    - Timer runs outside the engine.
3. Logs `C`.
4. Stack empty  run microtasks (none here)  run macrotask (`B`).
Enter fullscreen mode Exit fullscreen mode

Under the Hood — async/await:

async function example() {
  console.log('1');
  await Promise.resolve();
  console.log('2');
}

console.log('A');
example();
console.log('B');
Enter fullscreen mode Exit fullscreen mode

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`.
Enter fullscreen mode Exit fullscreen mode

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');

Enter fullscreen mode Exit fullscreen mode

Output:


1: Synchronous start
4: Synchronous end
2: queueMicrotask
3: setTimeout
Enter fullscreen mode Exit fullscreen mode

Why?

  • queueMicrotask runs after the current synchronous code, before any setTimeout.
  • 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; 

  }


Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 leverage requestIdleCallback for non-critical work.

References

  1. MDN: JavaScript Execution Model
  2. MDN: Microtask Guide
  3. Node.js Event Loop Guide
  4. JavaScript.info: Event Loop
  5. WHATWG HTML Spec: Event Loop Processing Model
  6. MDN: queueMicrotask
  7. MDN: setTimeout
  8. MDN: requestIdleCallback

Top comments (0)