Asynchronous JavaScript: Callbacks, Promises, async/await and fetch

MERN Stack Full Course: MongoDB, Express, React and Node.js · Foundations · 5 min read · Updated

Waiting without freezing

  1. Some jobs take time, such as a network request. JavaScript must not freeze while it waits.
  2. Your code starts the request with fetch and gets a promise: a placeholder for a future value.
  3. Meanwhile the event loop keeps the page responsive and runs other code.
  4. When the reply arrives, the promise resolves.
  5. await pauses only your function, and try/catch handles a failure. The result comes back to your code.

Some things take time: reading a file, calling a database, asking another server for data. JavaScript has one main thread, so if it waited for each slow task, everything else, including the page or the server, would freeze. Instead it starts the task, carries on, and comes back when the result is ready. This is asynchronous programming, and every MERN app is full of it: each database query, each API call and each file upload is asynchronous.

Callbacks: the first idea

A callback is a function you give to something else to call later. setTimeout(() => console.log('done'), 100) means: run this function in 100 milliseconds. Callbacks work, but when one slow task depends on the result of another, you end up nesting them deeper and deeper, which is hard to read and hard to handle errors in.

Promises: a value that arrives later

A promise is an object that stands for a result that is not ready yet. It is in one of three states: pending, fulfilled (it has a value), or rejected (it has an error). You attach handlers with .then() for success and .catch() for errors, and you can chain them. Most libraries you will use, including fetch and Mongoose, return promises.

async and await: promises that read like normal code

Putting async in front of a function lets you use await inside it. await pauses that function (not the whole program) until the promise settles, then gives you the value. Errors become normal exceptions, so you catch them with try / catch. Compare the code in the file below: it does the same thing three ways.

// Lesson 4: asynchronous JavaScript.   Run:  node examples/04-async/async-tour.js
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// 1) A callback: "call me when you are done" (the old style)
setTimeout(() => console.log('callback: 100 ms passed'), 100);

// 2) A promise: a value that arrives later
const loadJob = (id) =>
  new Promise((resolve, reject) => {
    setTimeout(() => (id > 0 ? resolve({ id, role: 'Teller' }) : reject(new Error('Bad id'))), 50);
  });
loadJob(1).then((job) => console.log('promise:', job.role)).catch((err) => console.log(err.message));

// 3) async / await: the same thing, written like normal top-to-bottom code
async function main() {
  await wait(150);
  try {
    const job = await loadJob(2);
    console.log('await:', job.role);
    await loadJob(0); // this one rejects
  } catch (err) {
    console.log('caught:', err.message); // errors from await are caught with try/catch
  }

  // run things at the same time, wait for all of them
  const [a, b] = await Promise.all([loadJob(3), loadJob(4)]);
  console.log('Promise.all:', a.id, b.id);

  // fetch is built into Node 18+ and into every browser
  const server = (await import('node:http')).createServer((req, res) => res.end(JSON.stringify({ ok: true }))).listen(0);
  const res = await fetch(`http://localhost:${server.address().port}/`);
  console.log('fetch:', res.status, await res.json());
  server.close();
}
main();

Run it and read the output order carefully. The promise result prints before the callback, because the promise takes 50 ms and the callback 100 ms. Understanding that things finish in the order of their delays, not in the order you wrote them, is the key idea of this lesson.

Run tasks at the same time

If two things do not depend on each other, do not await them one after the other. await Promise.all([a(), b()]) starts both at once and waits for both, so the total time is the slower of the two instead of the sum. You will use this pattern in lesson 18 to fetch a page of jobs and the total count in one go.

fetch: calling an API

fetch(url) is built into every modern browser and into Node.js 18 and newer. It returns a promise of a Response. Two details trip up almost every beginner. First, fetch only rejects when the network fails; a 404 or 500 answer is still a successful fetch, so you must check response.ok yourself. Second, reading the body is also asynchronous: await response.json(). The tour file shows the whole sequence.

Your task

  1. Run the tour and note the order of the printed lines.
  2. Change the delay in the callback to 10 ms and run it again. Which line moves, and why?
  3. Write an async function loadTwo() that uses Promise.all to load two jobs, and time it with console.time. Then do it again with two separate awaits and compare the times.
  4. Make fetch fail on purpose by calling a port nothing listens on, and catch the error with try / catch.

How to check it worked

  • You can predict the order of the output before you run the file.
  • Your parallel version takes about as long as one request, not two.

Summary

  • Slow tasks do not block JavaScript. They finish later and hand back a result.
  • Promises represent that result; async and await make them readable.
  • try / catch handles errors; Promise.all runs independent tasks together.
  • fetch does not reject on 404 or 500; check response.ok.

Download the solution (mern-lesson-04.zip)

Frequently asked questions

Is async code the same as running things on several threads?

No. JavaScript runs your code on one thread. The waiting for files, networks and databases happens outside it, and your code continues when the result is ready.

When should I use Promise.all?

When tasks do not depend on each other, such as loading a list and a count. If one needs the result of the other, await them in order. Note that Promise.all rejects as soon as any one of them fails.

Why does fetch not throw an error for a 404?

Because from the network's point of view the request worked: the server answered. You decide what a 404 or 500 means in your app by checking response.ok or response.status.