Learning web development: Asynchronous JavaScript – Promises and async functions

[2025-09-02] dev, javascript, learning web dev
(Ad, please don’t block)

This blog post is part of the series “Learning web development” – which teaches people who have never programmed how to create web apps with JavaScript.

To download the projects, go to the GitHub repository learning-web-dev-code and follow the instructions there.

I’m interested in feedback! If there is something you don’t understand, please write a comment at the end of this page.


In this chapter, we learn how to handle tasks that take a long time to complete – think downloading a file. The mechanisms for doing that, Promises and async functions are an important foundation of JavaScript and enable us to do a variety of interesting things.

This is a challenging chapter  

This chapter tackles some challenging topics. You may not immediately understand everything. However, that is normal:

  • Give yourself time. Let things rest. Rearead some sections one or more days later.
  • Study and experiment with the code.
  • It may also help to check out other resources (articles, videos, etc.) on the web that take a different approach to explaining these topics. MDN is a safe first resource you can go to.

The data structure queue  

A queue is a data structure to which we add values, which we then retrieve from it later. It works like a queue of people in front of a ticket booth: The first value we retrieve is the first value that was added. The second value we retrieve is the second value that was added. Etc. That’s why a queue is also called a FIFO data structure: First In First Out. JavaScript arrays can be used as queues, via the following two methods:

  • array.push(v) adds a value v to the end of the array.
  • array.shift() retrieves and removes the first element of the array.

This is again similar to queues of humans: People join the queue at the end and leave it once they are first in line.

The following code demonstrates how we can use an array as a queue:

const queue = [];

queue.push('a');
queue.push('b');
assert.deepEqual(
  queue, ['a', 'b']
);

assert.equal(
  queue.shift(), 'a'
);
assert.deepEqual(
  queue, ['b']
);

queue.push('c');
assert.deepEqual(
  queue, ['b', 'c']
);
  • We .push() values in this order: 'a', 'b', 'c'.
  • .shift() receives the values in the same order.

If we invoke .shift() on an empty array, it returns undefined:

> [].shift()
undefined

JavaScript code runs in a single thread  

Modern operating systems are capable of multitasking: More than one task (think piece of code) can run at the same time. In contrast, most JavaScript code is single-tasked:

  • The environment in which tasks run is called a thread.
  • By default all JavaScript tasks run in the main thread of a browser or Node.js: They run sequentially (one after another) and are managed by the event loop – which looks like this:
while (true) {
  const task = taskQueue.shift(); // (A)
  task();
}

If the task queue is empty, the .shift() in line A waits until there is a task.

In other words: The task queue constantly fills the main thread with code to run.

Why the “event” in the name “event loop”? While a browser runs JavaScript in a single thread, it runs other functionality in other threads: User input such as mouse clicks are received in a different thread (that runs concurrently to the main thread). Each user input is added as task to the queue. That task invokes the appropriate event listeners. The next two subsections describe other ways of adding tasks to the queue.

Adding a task once via setTimeout()  

The following function adds a task to the event queue, after a delay specified in milliseconds (1000 milliseconds are one second).

setTimeout(task, delay);

Note that the adding of a task is managed outside the main thread and relatively precise. However, the actual execution may be delayed – depending on how full the queue is when the task is added.

This is what using setTimeout() looks like:

setTimeout(
  () => {
    console.log('One second later');
  },
  1000
);

Why would we want to use setTimeout()? A long task can literally freeze the browser and stop it from accepting any user input. setTimeout() enables the current task to take a break, allow the main thread to process user input and continue after that. We’ll explore how that works in more detail soon.

Adding a task repeatedly via setInterval()  

setInterval() works similarly to setTimeout(). However, where the latter only adds a task once, the former adds the tasks many times (until it is stopped):

const id = setInterval(task, delay);
clearInterval(id);

setInterval() returns an id – which we can pass to clearInterval() in order to stop task from being run again. delay determines the wait (in milliseconds) before each invocation of the task.

We’ll use setInterval() in the next project.

Class Date  

We’ll need some date time functionality for the next two projects. Date is a class for objects that represent date time values. Note that class Date has many limitations. There is already a replacement called Temporal but it is not yet supported by many JavaScript platforms.

Creating a date time string for the current moment  

Consider the following interaction:

> new Date().toISOString()
'2161-10-11T09:42:21.117Z'

We created a Date object for the current moment and converted it to a string that conforms to the ISO standard notation for date time values.

How many milliseconds have passed since a starting point?  

The method Date.now() returns the number of milliseconds that have passed since zero o’clock on January 1, 1970, UTC. Therefore, it helps us with determining how many milliseconds have passed:

const sleep = (milliseconds) => {
  let start = Date.now();
  while ((Date.now() - start) < milliseconds);
};

Note that this is not a good way to wait! We’ll use it soon to demonstrate how a task can completely block the browser.

string.slice()  

The string method .slice() works similarly to the array method .slice():

> '2161-10-11T09:42:21.117Z'.slice(11, 19)
'09:42:21'

We extracted a fragment of the string that .slice() was invoked on: It starts at index 11 and ends before index 19.

Project: log-time.js  

log-time.js is a shell command that logs the current time to the terminal:

setInterval(
  () => {
    const d = new Date();
    console.log(
      d.toISOString().slice(11, 19)
    );
  },
  // Call function once every second
  1000 // milliseconds
);

Project: block-browser.html  

The main thread of a browser does not use run JavaScript, it also handles updating the content displayed on screen. Therefore, a long-running task can completely block the browser because no other functionality can run in the main thread. This project shows what that looks like. It has the following user interface:

<p>
  <a id="block" href="">Block browser for 5 seconds</a>
<p>
<button>This is a button</button>
<div id="statusMessage"></div>

The idea is that we click “Block” and a long-running loop is executed via JavaScript. During that loop, we can’t click the button because the main thread is blocked.

setStatusMessage('Blocking...');
setTimeout(
  () => {
    sleep(5000); // blocks the browser
    setStatusMessage('Done');
  },
  1
);

We have already seen how sleep() is implemented. setStatusMessage() is implemented like this:

const setStatusMessage = (msg) => {
  document.querySelector('#statusMessage').innerText = msg;
};

Delivering results asynchronously via Promises  

Now that we know how problematic long-running tasks are: What do we do if something takes a long time? We somehow have to pause the current task and come back to it later, so that other tasks get a chance to run.

In such cases, the following approach is used:

const promise = downloadText(someUrl);
promise.then(
  (str) => {
    // Process `str`
  }
);

downloadText() takes a long time to deliver its result. Therefore, it can’t immediately return it (at least not without blocking the browser). Instead, it returns a Promise – a placeholder for the actual result (which has yet to be delivered). With that Promise, we use method .then() to register an event listener that is invoked once the result is ready.

A function that delivers results via Promises is called asynchronous. Normal functions are called synchronous – we immediately get results.

In this section, we briefly explore the production side of Promises. After that, we explore tools for consuming Promises.

The states of a Promise  

These are the three states of a Promise:

  • Pending: The operation represented by this process is still ongoing.
  • Fulfilled: A result is ready to be retrieved.
  • Rejected: An error happened. We can access an error value.

A Promise that isn’t pending anymore is called settled – it’s either fulfilled or rejected.

Creating a Promise  

This is how we can create a Promise:

new Promise(
  (resolve, reject) => {
    // Invoke resolve() and/or reject()
  }
);

The callback of new Promise() contains the code associated with the Promise. It can call its parameter resolve() in order to fulfill the new Promise or reject() in order to reject it.

Example: waiting until an HTML element is clicked  

If we call the following function, it returns a Promise that is only resolved once the provided HTML element was clicked:

const waitForClick = (elem) => {
  return new Promise(
    (resolve, reject) => {
      elem.addEventListener(
        'click',
        (event) => {
          event.preventDefault();
          resolve(undefined); // (A)
        },
        { once: true } // (B)
      );
    }
  );
};

The returned Promise is pending until it is fulfilled in line A. This function does not really have a result, so we fulfill it with undefined. Omitting the argument would have had the same effect.

In line B, we use the third parameter of .addEventListener() to tell it that our event listener should only be invoked once.

Synchronous functions vs. asynchronous functions  

It may be helpful to compare how synchronous and asynchronous functions deliver results and errors. setTimeout() is used as a stand-in for any operation that takes some time to complete.

In case of success, they deliver a result:

const successSync = () => {
  return 123;
};
const successAsync = () => {
  return new Promise(
    (resolve, reject) => {
      setTimeout(
        () => resolve(123)
      );
    }
  );
};

In case of failure, they report an error:

const failureSync = () => {
  throw new Error();
};
const failureAsync = () => {
  return new Promise(
    (resolve, reject) => {
      setTimeout(
        () => reject(new Error())
      );
    }
  );
};

Async functions and await: handling results delivered via Promises  

We can work directly with Promises and receive fulfillment and rejection values via callbacks. But there is an alternative that is more convenient: async functions. This is an example:

const asyncFunc = async () => {
  console.log('Before');
  const result = await functionThatReturnsAPromise();
  console.log('After');
};

When we call asyncFunc(), it logs 'Before' and then returns. Why? Because the execution of the function was paused via await. As soon as the Promise that we await is settled, execution continues:

  • If the Promise is fulfilled, the fulfillment value is stored in result and the function logs 'After'.
  • If the Promise is rejected, an exception is thrown and the function is terminated early (before it can log 'After').

Pausing via await is also called suspending the function that surrounds it. Continuing the execution of the function is also called resuming it.

Creating Promises via Promise.resolve() and Promise.reject()  

The following two methods are not used very often, but they let us experiment with await in a JavaScript console:

  • Promise.resolve(v) creates a Promise that is already fulfilled with the value v.
  • Promise.reject(e) creates a Promise that is already rejected with the value e.

Let’s use these methods in a console:

> await Promise.resolve('Success!')
'Success!'
> await Promise.reject(new Error('Failure'))
Error: Failure

The first await produces a value. The second await throws an exception.

Project: wait-for-click.html  

We have already seen the code of waitForClick(). Project wait-for-click.html uses await to handle the Promise it returns. It has the following user interface:

<p>
  <a href="">Click here!</a>
</p>
<p id="status"></p>

This is the JavaScript behind the user interface:

await waitForClick(document.querySelector('a'));
document.querySelector('#status')
  .innerText = 'Clicked';

Previously, we needed an event listener to wait for a click. In this case, we only need to await a Promise.

The result of an async function  

An async function always returns a Promise:

> const asyncFunc = async () => {};
> asyncFunc() instanceof Promise
true

The result Promise of an async function is returned the first time its execution pauses (await) or stops permanently (return, throw). It is settled in two ways:

  • return v fulfills the result Promise with the value v. Not returning anything is equivalent to returning undefined.
  • throw e rejects the result Promise with the value e.

Async functions translate between sync and async  

It’s interesting how an async function translates between the worlds of sync code and async code:

  • Inside, an async function is synchronous: await converts a Promise (something asynchronous) to a value or an exception (something synchronous).
  • To the outside, an async function is asynchronous: A (synchronous) return fulfills its result Promise and a (synchronous) throw rejects its result Promise.

What happens if we omit await?  

Let’s use the following code to explore what happens if we invoke an asynchronous function without await:

const logAfterOneSecond = () => {
  return new Promise(
    (resolve) => { // (A)
      console.log('Waiting for one second...');
      setTimeout(
        () => {
          console.log('Logged');
          resolve(undefined); // (B)
        },
        1000
      );
    }
  );
};
console.log('Before');
await logAfterOneSecond(); // (C)
console.log('After');

In line A, we omit the second parameter reject because we don’t need it.

This is what the output looks like with the await in line C: We wait until the Promise returned by logAfterOneSecond() is settled in line B.

Before
Waiting for one second...
Logged
After

If we remove the await in line C, the output looks like this:

Before
Waiting for one second...
After
Logged

So what happens?

  1. An asynchronous function always starts synchronously: within the current task.
  2. But its result is always delivered asynchronously: in a future task (remember that what happens after an await runs in a new task).

Without await, part 1 runs before 'After'. But then we don’t wait for part 2 and log 'After'. Then part 2 happens.

Asynchronous code is viral  

An asynchronous function f() has an interesting viral nature: We can’t really invoke f() without await. That means that wherever we invoke f() from has to be asynchronous, too. And so on!

Node.js’s asynchronous fs functions  

We have already used the following two functions from node:fs to read and write text files:

  • fs.readFileSync()
  • fs.writeFileSync()

However, there are also asynchronous versions of these functions (note the different module name in the first line):

import * as fs from 'node:fs/promises';

const str = await fs.readFile(filePath, 'utf-8');
await fs.writeFile(filePath, str);

Why would we want to use these versions? They are less convenient to use but they don’t block the main thread while they are doing their work. Such blocking doesn’t matter in shell command, but it does matter if we implement a web server with Node.js (which we’ll do in a future chapter). There, we want the main thread to be unblocked and ready to handle more incoming requests.

fetch()  

fetch() is a function that lets us download data from the web. We use it like this:

const response = await fetch(url);
const text = await response.text();
  • Step 1: fetch() asynchronously returns a response object for the file at url.
  • Step 2: Method .text() of the response object asynchronously returns the content of the file as a string.

In principle, we can also combine the two steps but that looks kind of ugly to my eyes:

const text = await ((await fetch(url)).text());

response has more methods – e.g., response.json() which parses the JSON in a file for us, saving us an extra step we’d have to do after .text().

Project: log-url-text.js  

The shell command log-url-text.js looks like this:

const url = process.argv[2];
const response = await fetch(url);
const text = await response.text();
console.log(text);

We invoke it as follows:

node log-url-text.js https://example.com

Project: random-quote-browser/  

Project random-quote-browser is the browser version of project random-quote-nodejs. We now have the following HTML user interface for the quote:

<p>
  <button disabled>Show random quote</button>
</p>
<div id="quote"></div>
<div id="author"></div>

This function loads the quotes from a JSON file that sits next to the HTML file:

const loadQuotes = async () => {
  const quotesUrl = new URL('quotes.json', import.meta.url); // (A)
  const quotesResponse = await fetch(quotesUrl);
  const quotes = await quotesResponse.json();
  return quotes;
};

In line A, we once again construct a URL object for a file that is a sibling of the current file. We then use fetch() to download the sibling file.

This is the remaining JavaScript code of the project:

const quotes = await loadQuotes();
showQuoteButton.addEventListener(
  'click',
  () => {
    const randomIndex = getRandomInteger(quotes.length);
    const randomQuote = quotes[randomIndex];
    
    quoteElem.innerText = randomQuote.quote;
    authorElem.innerText = '— ' + randomQuote.author;
  }
);
showQuoteButton.disabled = false; // (π)

Note that the <button> is initially disabled. That ensures that the user doesn’t click it before everything is ready. Once it is, we enable the button (line π). The main reason we are doing that is that downloading the quotes (line 1) make take some time.

Exercises (without solutions)  

  • item-store.js: Rewrite the code so that it uses module 'node:fs/promises'.

    • All functions that perform file operations have to become async (because they use await to invoke fs methods).
    • main() must become async too. And you must await its invocation in the last line. That’s the virality we have talked about previously.
  • flash-cards: Replace data.js with a JSON file that is downloaded via fetch().

  • log-time.js: Create a web version of this shell command.