Home / Articles

How JavaScript Runs Your Code: Stack, Heap, Event Loop and Generators

A guided tour of the JavaScript runtime: call stack and frames, heap references, the Node.js event loop, the DOM, callbacks, promises, async/await and generators.

Publisher: REACTAPP.TOP13 min read2982 words

This article is published in English.

Sources: JavaScript

Asynchronous JavaScript often behaves in ways that surprise developers: a log line appears "too early", an await seems to skip ahead, a generator prints half of its messages. Almost every one of those surprises disappears once you have a clear model of how the runtime executes code. This article builds that model piece by piece, from the call stack and heap through the Node.js event loop and the DOM to promises, async/await and generators, so you can predict each snippet's output before reading it.

The pieces of the runtime

JavaScript runs on a single main thread, yet programs wait for timers, network responses and files without freezing, because the engine does not act alone:

  • the call stack, which tracks the functions currently executing
  • the heap, where objects and other dynamically allocated data live
  • host APIs (Web APIs in browsers, Node.js APIs on the server), which handle timers, files and networking
  • callback (task) queues, where completed work waits to run
  • the microtask queue, used mainly by promises
  • the event loop, which decides what runs next

Your code executes on the call stack while the runtime handles slow operations. When one completes, its callback is queued, and the event loop moves it onto the stack once the stack is free.

The Node.js event loop at a glance

The diagram shows the Node.js cycle. Synchronous code runs first, then queued microtasks, then the loop's phases: timers (setTimeout, setInterval), pending callbacks, poll (where most file and network callbacks run), check (setImmediate) and close callbacks.

┌───────────────────┐
│    CALL STACK     │
│    JavaScript     │
└─────────┬─────────┘
          │
          │ Synchronous code finishes
          ▼
┌───────────────────┐
│  MICROTASK QUEUE  │
│                   │
│  Promise.then()   │
│  Promise.catch()  │
│  queueMicrotask() │
└─────────┬─────────┘
          │
          ▼
╔═════════════════════════════════════════════╗
║                EVENT LOOP                   ║
║                                             ║
║  ┌───────────────┐                          ║
║  │     TIMERS    │                          ║
║  └───────┬───────┘                          ║
║          ▼                                  ║
║  ┌───────────────┐                          ║
║  │    PENDING    │                          ║
║  │    CALLBACKS  │                          ║
║  └───────┬───────┘                          ║
║          ▼                                  ║
║  ┌───────────────┐                          ║
║  │     POLL      │                          ║
║  │      I/O      │                          ║
║  └───────┬───────┘                          ║
║          ▼                                  ║
║  ┌───────────────┐                          ║
║  │     CHECK     │                          ║
║  │  setImmediate │                          ║
║  └───────┬───────┘                          ║
║          ▼                                  ║
║  ┌───────────────┐                          ║
║  │     CLOSE     │                          ║
║  │    CALLBACKS  │                          ║
║  └───────┬───────┘                          ║
║          └───────────────────────────────┐  ║
║                                          │  ║
╚══════════════════════════════════════════╪══╝
                                           │
                                           ▼
                                  ┌───────────────────┐
                                  │    CALL STACK     │
                                  │    Execute JS     │
                                  └───────────────────┘

Two refinements matter. Microtasks are not processed only once per loop: Node drains the microtask queue (and the process.nextTick queue before it) after each callback, so a promise's .then handler runs before the next timer callback. And browsers share the tasks-plus-microtasks principle but have no setImmediate or libuv phases, and they interleave rendering. For the Node-specific details, see how libuv, the event loop and the thread pool work together.

The call stack

The call stack records which function is running and which functions are waiting for it to return. It is a LIFO structure, last in, first out: the most recently called function must finish before the one below it can continue.

In the following example, func1 calls func2, which calls func3, which logs a message:

function func1() {
  func2();
}
function func2() {
  func3();
}
function func3() {
  console.log("Hello");
}
func1();

The calls pile up in this order:

func1()
   ↓
func2()
   ↓
func3()
   ↓
console.log()

Like a stack of plates, console.log goes on last and comes off first, then func3, func2 and func1 return in turn. Queued callbacks can only run once the stack is empty, which is why a long synchronous loop blocks everything else.

The heap

The heap is memory for data whose lifetime is not tied to a single call, such as objects, arrays and closures.

Creating an object literal allocates it there:

const user = {
  name: "Rayane",
  age: 20
};

The variable holds only a reference to the object stored in the heap:

CALL STACK                  HEAP
┌───────────────┐          ┌────────────────────┐
│ user variable │─────────▶│ {                  │
│               │          │   name: "Rayane",  │
└───────────────┘          │   age: 20          │
                           │ }                  │
                           └────────────────────┘

Assigning user to another variable copies the reference, not the object, so changes through either are visible through both. Once no reachable reference points to an object, the garbage collector can reclaim it; a long-lived closure that captures an object keeps it alive.

What the event loop coordinates

The event loop ties your code to the completion of asynchronous work. In Node.js, V8 runs your code; a call to a Node API, such as reading a file, goes to libuv, which hands it to the operating system or its thread pool. When the work completes, the callback becomes ready and the event loop eventually runs it on the stack.

JavaScript code
      │
      ▼
   V8 Engine
      │
      ▼
  Call Stack
      │
      ▼
Node.js APIs
      │
      ▼
    libuv
      │
      ├───────────────┐
      ▼               ▼
     OS           Thread Pool
      │               │
      └───────┬───────┘
              ▼
     Asynchronous operation
              │
              ▼
     Callback becomes ready
              │
              ▼
         Event Loop
              │
              ▼
       Callback Queue
              │
              ▼
         Call Stack

The route depends on the operation: sockets are typically handled by the OS through epoll or kqueue, while file-system work and some crypto and DNS calls use the thread pool.

Stack frames

Each call pushes a stack frame holding what the function needs while it runs:

  • its parameters
  • its local variables
  • the return address, meaning where execution continues in the caller
  • other engine bookkeeping

Take a function that adds two numbers:

function add(a, b) {
  const result = a + b;
  return result;
}
add(2, 3);

While add(2, 3) is executing, its frame contains the arguments, the local result and a link back to the caller:

     CALL STACK
┌─────────────────────┐
│   stack frame: add  │
│                     │
│   a = 2             │
│   b = 3             │
│   result = 5        │
│   return → caller   │
└─────────────────────┘

Once it returns, the frame is discarded:

     CALL STACK
┌─────────────────────┐
│   frame removed     │
└─────────────────────┘

The whole lifecycle is compact:

Function call
      ↓
Stack Frame created
      ↓
Function executes
      ↓
Function returns
      ↓
Stack Frame removed

Recursion that goes too deep piles up frames until the engine throws a RangeError for exceeding the maximum call stack size.

The DOM

The Document Object Model is the browser's live tree of nodes built from an HTML document. Through it, JavaScript can:

  • read elements
  • change their content
  • insert new elements
  • remove existing ones
  • modify styles and attributes

The DOM is a browser API, not part of the language, which is why document does not exist in plain Node.js.

Here is a single heading in HTML:

<h1 id="title">Hello</h1>

A script can obtain a reference to that element by its id:

const title = document.getElementById("title");

and then replace its text:

title.textContent = "Hello Rayane!";

Before the change, the element's child text node holds the original string:

document
   │
   ▼
<h1 id="title">
   │
   ▼
"Hello"

After the same assignment runs:

title.textContent = "Hello Rayane!";

the tree reflects the new text:

document
   │
   ▼
<h1 id="title">
   │
   ▼
"Hello Rayane!"

The browser then repaints. In real code, check for the null that getElementById returns when nothing matches; and prefer textContent over innerHTML for untrusted strings, since it never parses HTML.

Callbacks

A callback is a function passed to another function so that it can be called later. In this example greet is handed to execute, which invokes it with a name:

function greet(name) {
  console.log(`Hello ${name}`);
}
function execute(callback) {
  callback("Ajami");
}
execute(greet);

The program prints:

Hello Ajami

Inside execute, the parameter named callback refers to greet, and this line calls it:

callback("Ajami");

A callback is not inherently asynchronous; this one runs immediately. It becomes asynchronous when the receiver triggers it after an event, such as a timer firing.

Promises

A promise represents the eventual result of an asynchronous operation. You start the operation, keep running other code, and handle the outcome once the promise settles.

fetch is a typical promise-returning API:

const promise = fetch("https://api.example.com/data");

You react to the result by attaching handlers:

  • .then() for a successful result
  • .catch() for a failure
  • .finally() for cleanup that runs in either case
fetch("https://api.example.com/data")
  .then((response) => {
    console.log("Request finished");
  })
  .catch((error) => {
    console.log("Something went wrong:", error.message);
  })
  .finally(() => {
    console.log("Request completed");
  });

Starting the request does not block; the handlers run later as microtasks. Note that fetch rejects only on network failures. A 404 or 500 still fulfills the promise, so check response.ok yourself.

Promise states

A promise is always in one of three states:

PENDING
   │
   ├───────────────┐
   ▼               ▼
FULFILLED       REJECTED
  • Pending: the operation has not completed yet.
  • Fulfilled: it completed successfully and has a value.
  • Rejected: it failed and has a reason, usually an error.

A promise settles exactly once, and handlers attached later still receive the settled result.

Async/await

An async function always returns a promise, and await lets you write asynchronous steps sequentially. This function logs, awaits a request and logs again, with another log after the call:

async function leetfinish() {
  console.log("The function starts");
const res = await fetch("https://api.example.com/finish");
  console.log("The request finished");
}
leetfinish();
console.log("Let's review our code");

A likely output is:

The function starts
Let's review our code
The request finished

Why the outer log runs second

The function prints its first message and reaches this line:

const res = await fetch("https://api.example.com/finish");

fetch returns a pending promise, and await suspends the function and hands control back to the caller without freezing the thread. The caller prints its message; only after the response arrives does the function resume. Hence:

The function starts
Let's review our code
The request finished

await pauses the function, not the thread. And "likely" matters: if the request fails, the last line never prints and the uncaught error becomes an unhandled rejection.

Handling errors with try, catch and finally

With async/await you use ordinary try/catch/finally. This example fetches data, validates the response and the budget, and always resets a loading flag. Its comments flag two details: HTTP errors do not reject, and response.json() also returns a promise.

let isLoading = true;
async function checkBudget() {
  try {
    // fetch() returns a Promise.
    // await pauses this function until the Promise settles.
    const response = await fetch(
      "https://api.example.com/khouribga/business/budget"
    );
    // fetch() does NOT reject automatically for HTTP errors
    // such as 404 or 500.
    if (!response.ok) {
      throw new Error("Failed to fetch business data");
    }
    // response.json() also returns a Promise.
    const business = await response.json();
    if (business.budget < 10) {
      throw new Error("Budget is too low");
    }
    console.log("Budget is sufficient:", business.budget);
  } catch (error) {
    console.log("Something went wrong:", error.message);
  } finally {
    // This runs whether the request succeeds or fails.
    isLoading = false;
    console.log("Loading finished:", isLoading);
  }
}
checkBudget();

Step by step:

  1. fetch() sends the request and await suspends the function.
  2. When the response arrives, the function resumes.
  3. A failed HTTP status is turned into a thrown error.
  4. The JSON body is parsed and the budget validated.
  5. Any error in try, including network failures, lands in catch.
  6. finally always runs and clears the loading state.

Practice: running async functions in parallel

A good exercise is LeetCode problem 2721, which asks you to start several operations at once instead of awaiting each in turn, essentially writing your own Promise.all.

Generators

A generator function can pause and resume, producing values over time. It is declared with function*, with pause points marked by yield:

function* generatorAjami() {
  // Instructions ...
  yield i;
  // More instructions ...
  yield i;
  // More instructions ...
}

Compare yield with return:

  • return produces a value and ends the function.
  • yield produces a value and suspends the function, preserving its state.

Stepping through a generator

This generator prints a message before each of three yield statements:

function* generatorAjami() {
  console.log("First born in casa");
  yield 10;
  console.log("Second move to Khouribga");
  yield 20;
  console.log("Third live in Khouribga");
  yield 30;
}

Calling it runs none of the body:

const generator = generatorAjami();

You get a generator object, an iterator you pull values from with .next():

console.log(generator.next());

The first call runs up to the first yield, printing the first message:

First born in casa
{ value: 10, done: false }

Execution stops at this statement:

yield 10;

and hands back 10. Each .next() result has two properties:

  • value, the value that was yielded
  • done, whether the generator has finished

So the following result:

{ value: 10, done: false }

means "here is 10, and there is more to come".

Calling .next() a second time:

console.log(generator.next());

produces:

Second move to Khouribga
{ value: 20, done: false }

The second message appears only now because the generator resumes right after the previous yield.

A third call:

console.log(generator.next());

prints the third message and yields 30:

Third live in Khouribga
{ value: 30, done: false }

and a fourth:

console.log(generator.next());

reports that nothing is left:

{ value: undefined, done: true }

With no explicit return, the final value is undefined.

What a generator really is

A generator does not return several times: one execution starts and finishes once, but can be suspended and resumed to hand out many values. The first call runs up to yield 10:

generator.next()
       ↓
Execute instructions
       ↓
yield 10
       ↓
Pause and return { value: 10, done: false }

The second resumes and stops at yield 20:

generator.next()
       ↓
Resume after yield 10
       ↓
yield 20
       ↓
Pause and return { value: 20, done: false }

The third resumes and stops at yield 30:

generator.next()
       ↓
Resume after yield 20
       ↓
yield 30
       ↓
Pause and return { value: 30, done: false }

The fourth finds no more statements and reports completion:

generator.next()
       ↓
No more instructions
       ↓
Return { value: undefined, done: true }

for...of and spread syntax call .next() for you and stop at done: true. For more patterns, see pausable functions and lazy iteration with generators.

Practice: an infinite Fibonacci generator

LeetCode problem 2648 is a natural fit. The generator yields the current value, then advances both running values. The while (true) loop is safe because each iteration pauses at yield.

var fibGenerator = function* () {
  let a = 0;
  let b = 1;
  while (true) {
    yield a;
    const next = a + b;
    a = b;
    b = next;
  }
};

The state evolves like this:

a = 0, b = 1
     ↓
yield 0
     ↓
a = 1, b = 1
     ↓
yield 1
     ↓
a = 1, b = 2
     ↓
yield 1
     ↓
a = 2, b = 3
     ↓
yield 2

Values are computed lazily, one per .next() call; just never spread or iterate it without a limit.

Putting the model together

For Node.js: V8 runs your code with its stack and heap, Node APIs pass slow work to libuv with its event loop and thread pool, and the operating system does the actual networking.

┌─────────────────────────────────────────────┐
│                  NODE.JS                    │
│                                             │
│   ┌─────────────┐                           │
│   │     V8      │                           │
│   │             │                           │
│   │ Call Stack  │                           │
│   │ Heap        │                           │
│   └──────┬──────┘                           │
│          │                                  │
│          ▼                                  │
│   ┌───────────────┐                         │
│   │  Node.js APIs │                         │
│   └───────┬───────┘                         │
│           │                                 │
│           ▼                                 │
│   ┌───────────────┐                         │
│   │     libuv     │                         │
│   │               │                         │
│   │  ┌─────────┐  │                         │
│   │  │ Event   │  │                         │
│   │  │  Loop   │  │                         │
│   │  └─────────┘  │                         │
│   │               │                         │
│   │  Thread Pool  │                         │
│   └───────┬───────┘                         │
│           │                                 │
└───────────┼─────────────────────────────────┘
            │
            ▼
      ┌───────────────┐
      │      OS       │
      │               │
      │ Networking    │
      │ Kernel        │
      │ epoll/kqueue  │
      └───────────────┘

Key takeaways

  • Nothing queued runs until the call stack is empty, so long synchronous work blocks everything.
  • Variables hold references to heap objects; unreachable objects are collected.
  • The runtime performs slow work; promise handlers run as microtasks before the next callback.
  • fetch rejects only on network errors, so check response.ok.
  • await suspends the function, not the thread.
  • Generators pause at yield and resume on .next(), enabling lazy, even infinite, sequences.