Asynchronous JavaScript

Table of Contents

  1. Why Code Has to Wait
  2. The Event Loop, Simply
  3. Callbacks
  4. Promises
  5. async and await
  6. Running Tasks Together with Promise.all
  7. fetch: Talking to a Server
  8. Project: Dara's GitHub Repositories
  9. Try It Yourself

1. Why Code Has to Wait

So far, each line of your code finished before the next one began. That works when the work is fast. But some things are slow: loading data from a server, reading a large file, waiting for a timer. As you learned in How the Web Works, a network request travels across the internet and back, which can take from a few milliseconds to several seconds.

If JavaScript simply stopped and waited, the whole page would freeze. No clicks, no scrolling, nothing. JavaScript has only one main thread of execution, so it must never block it.

The solution is asynchronous code: start the slow task, keep working, and run a piece of code later, when the result is ready. This chapter shows the tools for that, from the oldest to the newest.

2. The Event Loop, Simply

Imagine a cook in a small kitchen, working alone. They put rice on the stove, and instead of staring at the pot, they chop vegetables. When the timer rings, they go back to the rice. The cook (the thread) does one thing at a time, but never wastes time waiting.

JavaScript works the same way. The browser (or Node) handles the slow work in the background. When it finishes, it places your waiting function in a queue. The event loop takes a function from the queue only when your current code is completely done.

You can see this with setTimeout, which runs a function after a delay in milliseconds:

console.log("1. start");

setTimeout(() => {
  console.log("3. timer");
}, 0);

console.log("2. end");

The output is 1. start, 2. end, then 3. timer. Even with a delay of 0, the timer function waits in the queue until the current code has finished. Remember this rule: a function you hand over for later never runs in the middle of your current code.

3. Callbacks

The first tool is a callback: a function you pass to another function, to be called when the work is done. You have already used callbacks in addEventListener and setTimeout.

function getUser(id, callback) {
  setTimeout(() => {
    callback({ id, name: "Dara" });
  }, 100);
}

getUser(1, (user) => {
  console.log(user.name); // "Dara"
});

Callbacks work, but they get messy when steps depend on each other. Load a user, then their projects, then the details of one project, and every step nests inside the last:

getUser(1, (user) => {
  getProjects(user.id, (projects) => {
    getDetails(projects[0].id, (details) => {
      // deeper and deeper...
    });
  });
});

This shape is nicknamed "callback hell." Errors are also hard to handle, because each level needs its own check. Promises were created to solve this.

4. Promises

A promise is an object that stands for a value you do not have yet. It is always in one of three states:

StateMeaning
pendingThe work is still running
fulfilledIt finished, and there is a result
rejectedIt failed, and there is an error

Here is a function that returns a promise. It succeeds for known project ids and fails for others:

function loadProject(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 3) {
        reject(new Error(`Project ${id} not found`));
      } else {
        resolve({ id, title: `Project ${id}` });
      }
    }, 100);
  });
}

Call resolve(value) for success and reject(error) for failure. The caller uses .then() for the result and .catch() for an error, and .finally() runs in both cases:

loadProject(1)
  .then((project) => {
    console.log(project.title); // "Project 1"
    return loadProject(9);      // returning a promise continues the chain
  })
  .then(() => console.log("never reached"))
  .catch((error) => console.log("Caught:", error.message))
  .finally(() => console.log("Done"));

The chain is flat instead of nested, and one .catch() at the end handles a failure from any step above it.

5. async and await

Promises are good, but async and await make them read like normal top-to-bottom code. Put async before a function, and await before anything that returns a promise. The function pauses at await (without blocking anything else) until the promise settles:

async function main() {
  try {
    const project = await loadProject(2);
    console.log(project.title); // "Project 2"

    await loadProject(7); // rejects, so the next line never runs
  } catch (error) {
    console.log("Caught:", error.message);
  }
}

main();

Three rules:

  • await works only inside an async function (or at the top level of a module, which you now use).
  • An async function always returns a promise.
  • Use ordinary try / catch to handle errors, as the next chapter explains in more depth.

6. Running Tasks Together with Promise.all

Two await lines in a row run one after the other. If the tasks do not depend on each other, that wastes time. Start both at once with Promise.all:

const [first, third] = await Promise.all([
  loadProject(1),
  loadProject(3),
]);

console.log(first.title, third.title); // "Project 1 Project 3"

Promise.all takes an array of promises and gives back an array of results in the same order. It finishes when all succeed, taking about as long as the slowest one. If any one fails, the whole thing rejects. When you want every result regardless of failures, use Promise.allSettled instead.

7. fetch: Talking to a Server

The browser's built-in fetch function sends an HTTP request and returns a promise. Node has it too. It is the same request-and-response idea from the web basics, in code:

const response = await fetch("https://api.github.com/users/octocat/repos");
const repos = await response.json();

Notice there are two awaits. The first resolves when the response headers arrive. The second, response.json(), reads the body and turns the JSON text into JavaScript objects.

There is a trap here that catches almost everyone. fetch rejects only when the network itself fails. A 404 Not Found or 500 response is still a "successful" fetch! You must check it:

if (!response.ok) {
  throw new Error(`Request failed with status ${response.status}`);
}

response.ok is true for status codes 200 to 299. Always check it before you call json().

8. Project: Dara's GitHub Repositories

Now add the last feature of the page: a list of Dara's public repositories, loaded from the GitHub API. The address is https://api.github.com/users/<username>/repos. Replace your-github-username below with a real account name (yours, or octocat).

First, in index.html:

<section id="repos">
  <h2>My GitHub Repositories</h2>
  <p id="repos-status" role="status"></p>
  <ul id="repos-list"></ul>
</section>

Then in a new module, js/repos.js:

const USERNAME = "your-github-username";
const url = `https://api.github.com/users/${USERNAME}/repos?sort=updated&per_page=6`;

const status = document.querySelector("#repos-status");
const list = document.querySelector("#repos-list");

async function loadRepos() {
  status.textContent = "Loading repositories...";
  list.replaceChildren();

  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`GitHub returned status ${response.status}`);
    }

    const repos = await response.json();
    if (repos.length === 0) {
      status.textContent = "No public repositories yet.";
      return;
    }

    for (const repo of repos) {
      const item = document.createElement("li");
      const link = document.createElement("a");
      link.href = repo.html_url;
      link.textContent = repo.name;
      item.append(link, ` · ${repo.language ?? "No language"}`);
      list.append(item);
    }
    status.textContent = "";
  } catch (error) {
    status.textContent = `Could not load repositories. ${error.message}`;
  }
}

loadRepos();

Load it from main.js with import "./repos.js";, or add a second <script type="module"> tag.

This is the standard shape of any data-loading code, and it handles all four states a user can see: loading, success, empty, and error. Without those, a visitor staring at a blank section cannot tell whether the page is broken or just slow.

Two things to know about the GitHub API:

  • Rate limits. Without logging in, GitHub allows about 60 requests per hour from one internet address. If you reload many times while testing, you may get status 403. Your error message will appear, which proves the error state works. Wait and try again.
  • Safe text. The repository name and language come from outside your code, so we put them in textContent and href, never innerHTML.

Open DevTools, choose the Network tab, and reload. You will see the request to api.github.com, its status code, and the JSON in the Response tab. Turn on "Offline" throttling and reload to see your error state.

9. Try It Yourself

  1. Predict the order. Write three console.log calls with a setTimeout(..., 0) in the middle. Guess the output, then run it in Node.
  2. Callback to promise. Take getUser from section 3 and rewrite it so it returns a promise. Use it with .then().
  3. Use async/await. Rewrite your promise chain with async, await, and try / catch. Make one id fail on purpose.
  4. Measure Promise.all. Create wait(ms) with new Promise((resolve) => setTimeout(resolve, ms)). Time two await wait(500) calls in a row, then in Promise.all. Use console.time and console.timeEnd.
  5. Show the 404. Change the username to something that does not exist, or add a typo to the URL. Confirm that your page shows the error message.
  6. Finish the project. Build the repos list. Sort it by stargazers_count, and show the number next to each name.

Next, we'll look closely at what happens when code fails: error types, try / catch, and how to debug with the console and the DevTools debugger.

Buy Me a Coffee

Found this tutorial helpful? Consider buying me a coffee to support more free content!