Skip to content
Educora
Advanced20 min12 / 27

Asynchronous JavaScript: callbacks, promises, async/await

Learn to do work that involves waiting without freezing the program: `setTimeout`, callbacks, promises and `async`/`await`.

Check yourself
In this lesson you will learn
  • Explain the difference between synchronous and asynchronous code and how setTimeout works
  • Create a promise and handle its result with then, catch and finally
  • Write readable asynchronous code with async/await and try...catch
  • Run several tasks in parallel with Promise.all

In a café the waiter passes your order to the kitchen but doesn't stand at the kitchen door until the plov is cooked — he serves other tables. When the food is ready, he brings it. JavaScript works the same way: while it waits for slow things like a server response, a timer or a file, it doesn't freeze the page but keeps doing other work. This is called asynchronous programming.

Synchronous and asynchronous

The code we have written so far was synchronous: each statement waits for the previous one to finish. setTimeout(function, ms) instead “puts a function aside” to run after the given number of milliseconds, and the program moves on to the next line immediately. Look at the order of the results:

JavaScript
console.log('1. Order placed');

setTimeout(() => {
  console.log('4. Plov is ready');
}, 1000);

setTimeout(() => {
  console.log('3. Tea is ready');
}, 0);

console.log('2. Waiter serves another table');
▸ Expected output
1. Order placed
2. Waiter serves another table
3. Tea is ready
4. Plov is ready
Definition
Event loop

JavaScript does only one thing at a time. Callbacks that are ready wait in a queue, and the event loop takes them from the queue one by one when the current synchronous code has finished. That is why even a setTimeout with a 0 ms delay runs after all the synchronous code.

Callbacks and their problem

The oldest way to get a result when async work is done is to pass a callback: “call this function when you're ready.” If several tasks must happen one after another, the callbacks nest inside each other and the code grows to the right like a staircase. Developers call this callback hell.

JavaScript
function cook(dish, callback) {
  setTimeout(() => callback(`${dish} is ready`), 300);
}

cook('Plov', (first) => {
  console.log(first);
  cook('Dolma', (second) => {
    console.log(second);
    cook('Pakhlava', (third) => {
      console.log(third);
    });
  });
});
▸ Expected output
Plov is ready
Dolma is ready
Pakhlava is ready

Promises

A promise is an object that stands for a result that will be ready in the future. It has three states: pending (waiting), fulfilled (completed successfully) and rejected (ended with an error). When creating a promise, we get two functions: resolve for success and reject for failure. We take the result with then and the error with catch; finally runs in any case.

JavaScript
function cook(dish, time) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (!dish) {
        reject(new Error('No dish ordered'));
      } else {
        resolve(`${dish} is ready`);
      }
    }, time);
  });
}

cook('Plov', 300)
  .then((message) => {
    console.log(message);
    return cook('Dolma', 200);
  })
  .then((message) => console.log(message))
  .then(() => cook('', 100))
  .catch((error) => console.log('Error:', error.message))
  .finally(() => console.log('Kitchen closed'));
▸ Expected output
Plov is ready
Dolma is ready
Error: No dish ordered
Kitchen closed
The staircase is gone: each then returns the next step, and all errors are caught in one catch.

async and await

async/await lets you write promise-based code as if it were ordinary synchronous code. A function declared with async always returns a promise. Inside it, await waits for a promise's result without freezing the rest of the program. Errors are caught with the familiar try...catch.

JavaScript
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function cook(dish, time) {
  await wait(time);
  if (!dish) throw new Error('No dish ordered');
  return `${dish} is ready`;
}

async function dinner() {
  try {
    console.log(await cook('Plov', 300));
    console.log(await cook('Dolma', 200));
    console.log(await cook('', 100));
  } catch (error) {
    console.log('Error:', error.message);
  } finally {
    console.log('Kitchen closed');
  }
}

dinner();
▸ Expected output
Plov is ready
Dolma is ready
Error: No dish ordered
Kitchen closed
The result is the same as with the promise chain, but the code is much easier to read.
JavaScript
async function getScore() {
  return 42;
}

const result = getScore();
console.log(result instanceof Promise);

result.then((score) => console.log('Score:', score));
▸ Expected output
true
Score: 42

Parallel tasks and fetch

If tasks don't depend on each other, waiting for them one by one wastes time. Promise.all starts all the promises at once and, when every one is ready, returns the results as an array in the same order. The three tasks below would take 600 ms one after another, but in parallel they take about 300 ms. If even one promise fails, Promise.all fails too.

JavaScript
const cook = (dish, ms) =>
  new Promise((resolve) => setTimeout(() => resolve(`${dish} is ready`), ms));

async function breakfast() {
  const results = await Promise.all([
    cook('Tea', 300),
    cook('Bread', 100),
    cook('Jam', 200),
  ]);
  console.log(results);
}

breakfast();
▸ Expected output
[ 'Tea is ready', 'Bread is ready', 'Jam is ready' ]

In real projects, the async operation you will meet most often is loading data from a server with fetch. fetch returns a promise; when the response arrives, response.json() turns it into a JavaScript object — and that is a promise too. The example below is a template: the address is just a placeholder.

JavaScript
async function loadWeather(city) {
  try {
    const response = await fetch(`https://api.example.com/weather?city=${city}`);
    if (!response.ok) {
      throw new Error(`Server answered ${response.status}`);
    }
    const data = await response.json();
    console.log(`${city}: ${data.temperature} °C`);
  } catch (error) {
    console.log('Could not load the weather:', error.message);
  }
}

loadWeather('Baku');
ApproachStrengthWeakness
Callbacksimple, works everywhereunreadable when nested
Promisechains, one catch for all errorslong chains are still hard to read
async/awaitreads like normal code, try...catcheasy to forget await
Exercise

Write the async function countdown(from): it prints the numbers from from down to 1, waits 300 ms after each number with wait(300), and finally prints Go!. The await in the last line works here just as it does at the top level of a module.

Exercise · JavaScript
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function countdown(from) {
  // print from, from - 1, ..., 1 with a 300 ms pause after each number
  // then print Go!
}

await countdown(3);
▸ Expected output
3
2
1
Go!

Key points

  • Asynchronous code doesn't freeze the program while waiting; setTimeout schedules a function for later.
  • Ready callbacks run only after all synchronous code has finished (the event loop).
  • A promise has three states: pending, fulfilled, rejected; take the result with then and the error with catch.
  • An async function always returns a promise; await waits for the result, and errors are caught with try...catch.
  • Promise.all runs independent tasks in parallel and returns the results in the same order.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
In what order does console.log('A'); setTimeout(() => console.log('B'), 0); console.log('C'); print?