Week 10 - Asynchronous programming

Synchronous vs Asynchronous Code

Callbacks

Promises

Asynchronous File I/O

Event Loop

Fetch API

Async/Await and Promise.all()

AI Using GitHub Copilot in VS Code

Practice

Assignment

Back to core program

The Event Loop

<aside> 💭

On this page we go deeper into how the JavaScript event loop works. You do not need to remember every detail for everyday programming. Focus on the big ideas: single thread, queues, and how Promises fit in.

</aside>

The Event Loop and Microtasks

JavaScript has a single main thread of execution. That means it can only run one piece of JavaScript code at a time.

However, JavaScript programs still do a lot of things that look concurrent: timers, I/O, network requests, etc. The Event Loop is the mechanism that makes this possible.

Call Stack and Tasks

When your code runs, JavaScript evaluates functions using a Call Stack:

If JavaScript only had a call stack, it could not handle long‑running operations without freezing. Instead, long‑running work (for example, a timer or network request) is usually offloaded to the host environment (for example, the browser or Node.js), and JavaScript is notified later when the result is ready.

Those “later” pieces of work are queued as tasks. The event loop’s basic job is:

  1. If the call stack is empty
  2. Take the next task from a queue
  3. Push its callback onto the stack and run it
  4. Repeat

This gives us the illusion of multiple things happening at once, while still keeping JavaScript single‑threaded.

Conceptually, each “tick” of the event loop looks like this:

  1. Run the current task on the call stack.
  2. When the stack is empty, run all microtasks (Promise callbacks).
  3. Then take the next task (e.g. a timer callback) from the task queue.
  4. Repeat.

Where Promises fit in

Promises add another layer: microtasks.

When you attach handlers with .then() or .catch(), JavaScript does not run those handlers immediately when the promise settles. Instead, it schedules them as microtasks.

Conceptually there are at least two kinds of queues:

The event loop processes them roughly like this:

  1. Run code on the call stack until it is empty.
  2. Then, drain the microtask queue:
  3. Then, take the next task from the task queue and run it.
  4. Go back to step 1.

The important consequence is:

Why this matters for your mental model

When you write code like:

console.log('Hello!');

Promise.resolve().then(() => {
  console.log('promise then');
});

console.log('Goodbye!');

the steps are:

  1. console.log('Hello!') runs on the stack.
  2. Promise.resolve().then(...) schedules the .then callback as a microtask.
  3. console.log('Goodbye!') runs on the stack.
  4. The stack becomes empty.
  5. The event loop sees a pending microtask and runs the .then handler.

So the output is:

Hello!
Goodbye!
promise then

Even though the promise is already resolved, its handler still goes through the microtask queue and does not interrupt the current synchronous code.

<aside> ⌨️

Check your understanding

If we add setTimeout(() => console.log('timeout'), 0); before the Promise.resolve().then(...), which line is printed first: "promise then" or "timeout"? Why?

</aside>

In the rest of this page we will use concrete examples to trace:

The Meaning of Life, revisited

In a previous chapter we looked at an example where we appended the answer to the Ultimate Question of Life, the Universe, and Everything to log file. We will now revisit a simplified form of this example, to explore how this code works with the JavaScript event loop.

Example 1: Resolved Promise

In this simplified example the answer promise is unconditionally resolved after two seconds whereafter the answer is written to the console.

function whatIsTheMeaningOfLife() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(42);
    }, 2000);
  });
}

function ask() {
  let text = new Date().toLocaleTimeString('nl-NL') + ' ';

  return whatIsTheMeaningOfLife()
    .then((result) => {
      text += `The answer is ${result}`;
    })
    .catch((err) => {
      text += `Error: ${err.message}`;
    })
    .then(() => {
      return console.log(text); // replaces: fsPromises.appendFile('answers.log', text + '\\n')
    })
    .catch((err) => {
      console.error('Failed to write to log file:', err);
    });
}

console.log('Hello!');
ask();
console.log('Goodbye!');

<aside> 💡

The callbacks of the two .then() methods and the first catch() hold a reference to the text variable outside of their own scope. They form closures.

</aside>

When you run this code the import line commented out as shown above, you will get this output similar to this:

Hello!
Goodbye!
19:40:48 The answer is 42

The first two lines are printed immediately, the next line with the answer 42 is printed after 2 seconds (2000 ms).

From this output we can conclude that not all code is executed in one go. There is no further code beyond the last console.log('Goodbye') statement, and when the JavaScript engine has executed that statement it has completed the current task. With that, it has nothing more to do and sits idle until the timer fires.

The Meaning of Life, visualized

The video below may help to strengthen your understanding of the activities outlined above for the example code. The video is intended to illustrate the processes at the conceptual level. In reality, a lot more steps are happening behind the scenes, particularly in the call stack.

Visualization of the Event Loop for the Meaning of Life example (no audio). Graphics and animation inspired by the videos from Lydia Hallie (see Additional Resources below).

Visualization of the Event Loop for the Meaning of Life example (no audio). Graphics and animation inspired by the videos from Lydia Hallie (see Additional Resources below).

<aside> ⌨️

</aside>

meaning-debug.png

Example 2: Rejected Promise

In this example the answer promise is unconditionally rejected after two seconds.

function whatIsTheMeaningOfLife() {
  return new Promise((resolve, reject) => {
    setTimeout(
      () =>
        reject(new Error('Come back in 7.5 million years and ask me again!')),
      2000
    );
  });
}

function ask() {
  // Same as previous
}

console.log('Hello!');
ask();
console.log('Goodbye!');

In example 2 with the rejected promise, the sequence of events involving the timer task and subsequent microtasks is similar as explained in detail for example 1, but with these differences: