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

Why Asynchronous Code?

Imagine you order a pizza online for home delivery. You do not stand at the door doing nothing until it arrives. You order it, then you watch TV, work on an assignment, or scroll your phone. Later, when the pizza is ready, the delivery person rings the bell.

JavaScript usually runs one thing at a time (we call this single-threaded). By default it is synchronous, which means it finishes one task before starting the next.

Some operations take a long time:

We do not want JavaScript to “freeze” the page while it waits.

Asynchronous code lets JavaScript start something, then do other work, and later come back when the long operation is finished.

Synchronous Example (Blocking)

function wait(delay) {
  console.log('Waiting...');

  const endTime = Date.now() + delay;
  let counter = 0;

  while (Date.now() < endTime) {
    counter += 1;
  }

  console.log('Finished waiting:', counter);
}

wait(1000);

for (let i = 1; i <= 5; i++) {
  console.log('Doing other work', i);
}

// Output Order:
// Waiting...
// Finished waiting: <huge number>
// Doing other work 1
// Doing other work 2
// Doing other work 3
// Doing other work 4
// Doing other work 5

In this example, the wait() function blocks execution for the specified delay:

So the main thread is blocked during the wait.

<aside> ⚠️

Do not write code like this in real projects. This kind of busy while loop blocks JavaScript from doing anything else. We only use it here to demonstrate how blocking (synchronous) code behaves.

</aside>

Asynchronous Example with setTimeout()

Now let’s look at an asynchronous version of wait using setTimeout(), which is non-blocking:

function wait(delay) {
  console.log('Waiting...');

  setTimeout(() => {
    console.log('Finished waiting');
  }, delay);
}

wait(1000);

for (let i = 1; i <= 5; i++) {
  console.log('Doing other work', i);
}

// Output Order:
// Waiting...
// Doing other work 1
// Doing other work 2
// Doing other work 3
// Doing other work 4
// Doing other work 5
// Finished waiting

What happens here?

More work can be done while waiting for the timer to complete.

For example, if you change the loop to go to 10000, those "Doing other work" messages will still appear immediately (although lots more of them), and "Finished waiting" will appear after the delay.

The timer that setTimeout() creates does not run inside the (single-threaded) JavaScript engine itself. It is managed by the (multi-threaded) host environment (the browser or Node.js).

When the timer finishes, JavaScript is notified and can run the function you passed to setTimeout().

You do not need to fully understand the “event loop” yet. For now, remember:

The function you pass to setTimeout() is called a callback function.

Callback Functions

A callback function is a function that you pass as an argument to another function. The other function decides when to call (or “call back”) that function.

A simple callback example

function greetLater(callback) {
  console.log('Preparing to greet...');
  callback(); // we decide when to call the function we got
}

greetLater(() => {
  console.log('Hello from the callback!');
});

Here:

<aside> ❗

A callback is just a normal function, but it is passed into another function and called from inside that function.

</aside>

Callback functions you already know

In Week 6 you already used callback functions with array methods:

Example:

const numbers = [1, 2, 3];

numbers.forEach((number) => {
  console.log(number);
});

The function (number) => { console.log(number); } is a callback:

Asynchronous callbacks

The callback functions used with asynchronous operations (like setTimeout()) are called later, when the operation completes.

For setTimeout():

setTimeout(() => {
  console.log('Finished waiting');
}, 1000);

At the time the callback runs, the function that scheduled it (for example, wait) has already finished.

Asynchronous Timers: setTimeout()

We already encountered setTimeout() in a previous section. This function schedules the execution of a callback function after a specified delay (in milliseconds). It fires once after the delay. It can be cancelled before it fires using clearTimeout().

Syntax

setTimeout(callback, delay);

where:

Asynchronous Timers: setInterval()

The setInterval() function is similar to setTimeout(), but it repeatedly executes a callback function at every specified interval until it is stopped.

Syntax

setInterval(callback, delay);

where:

Note: the first execution happens after an initial delay.

Example Usage

This example prints the current time every second for 10 seconds:

let count = 0;
const intervalID = setInterval(() => {
  count++;
  console.log(new Date().toLocaleTimeString('nl-NL'));

  if (count >= 10) {
    clearInterval(intervalID); // Stop the interval after 10 runs
    console.log('Interval stopped.');
  }
}, 1000); // Runs every second

Notes:

Callback Hell (The Problem)

As your programs grow, you may have many asynchronous operations that depend on each other. A common anti-pattern is to nest callbacks inside callbacks, forming a deep “pyramid” shape. This is called Callback Hell, or the Pyramid of Doom.

We will learn better tools later (Promises and async/await), but for now, here is a simplified example to show the shape:

fs.readFile(path1, (err, result1) => {
  if (err) {
    // handle error
    return;
  }

  fs.readFile(path2, (err, result2) => {
    if (err) {
      // handle error
      return;
    }

    fs.writeFile(path3, result2, (err) => {
      if (err) {
        // handle error
        return;
      }
      // done
    });
  });
});

Here you can see the typical visual shape of nested callbacks: Each new asynchronous operation is nested inside the previous one, moving further to the right.

Core issues with callback hell

  1. Readability
  2. Error handling
  3. Maintainability

<aside> 💡

</aside>

In the next section, we will see how Promises can help us write asynchronous code that is flatter and easier to understand. Here is a sneak preview of the nested callback code from above now rewritten with promises:

fs.promises.readFile(path1)
  .then((result1) => {
    return fs.promises.readFile(path2);
  })
  .then((result2) => {
    return fs.promises.writeFile(path3, result2);
  })
  .catch((err) => {
    // handle error
  });