Why JavaScript is asynchronous

Most code is synchronous: each line finishes before the next one starts. That is what you want for calculations, such as adding up the meter readings of a Kestrel Energy customer:

const readings = [412, 398, 455, 430];

let total = 0;
for (const kwh of readings) {
  total += kwh;
}

console.log("Readings:", readings.length);
console.log("Total kWh:", total);
console.log("Average:", total / readings.length);
Readings: 4
Total kWh: 1695
Average: 423.75

Some operations take a long time compared with a line of code: asking a server for data, reading a file, or waiting two seconds before showing a message. A JavaScript engine runs your code on a single thread, so if it simply stopped and waited, nothing else could happen in the meantime. In a browser that would freeze the whole page: no scrolling, no clicks, no animations.

JavaScript therefore uses asynchronous programming. Your code starts a slow operation, hands the waiting to the environment (the browser or Node.js) and carries on. When the result is ready, the environment arranges for a function of yours to run with it. In this example setTimeout() stands in for a slow request:

console.log("1. Request meter reading");

setTimeout(() => {
  console.log("3. Meter reading arrived");
}, 100);

console.log("2. Carry on with other work");
1. Request meter reading
2. Carry on with other work
3. Meter reading arrived

The program does not stop at setTimeout(). Line 2 prints first, and the function passed to setTimeout() runs 100 milliseconds later. Such operations are called non-blocking. Over the years JavaScript has gained three ways to write this kind of code, and this page follows them in order: callbacks, promises, and async/await.

The event loop

What decides when that later function runs is the event loop. Three parts work together:

  • The call stack holds the functions that are running right now. JavaScript only ever runs the function at the top.
  • The task queue holds callbacks that are ready to run: a timer that expired, a network response that arrived, a click. One item from this queue is called a task.
  • The microtask queue holds small follow-up jobs, mostly the reactions to promises.

A microtask is a job that runs as soon as the current piece of code has finished, before the event loop takes the next task. The rule of the loop is: run the current script or task until the call stack is empty, then run every waiting microtask (including microtasks those microtasks add), then take one task, and repeat. A running function is never interrupted; JavaScript only switches between jobs when the call stack is empty.

The event loop: the call stack hands slow work to the browser or Node.js APIs, which put callbacks in the task queue; promise reactions go to the microtask queue. When the stack is empty, all microtasks run first, then one task. Call stack main script readMeter() Browser or Node.js APIs timers, network, files hand off slow work Task queue: timers, I/O, events Microtask queue: promise reactions 2. one task 1. all microtasks event loop
Whenever the call stack is empty, the event loop first empties the microtask queue, then moves one task onto the stack.

You can see the rule at work. Promise.resolve().then(…) and queueMicrotask(…) schedule microtasks; setTimeout(…, 0) schedules a task:

console.log("script start");

setTimeout(() => console.log("timeout callback (task)"), 0);

Promise.resolve().then(() => console.log("then callback (microtask)"));

queueMicrotask(() => console.log("queueMicrotask callback (microtask)"));

console.log("script end");
script start
script end
then callback (microtask)
queueMicrotask callback (microtask)
timeout callback (task)

The two console.log calls of the script itself come first, because the script is the current task and runs to the end. Then both microtasks run, in the order they were queued. The timer callback comes last, even with a delay of 0, because it is a task and has to wait for the next turn of the loop.

Timers: setTimeout and setInterval

setTimeout() schedules a function to run once, after at least the given number of milliseconds. It is not part of the ECMAScript language itself; browsers and Node.js provide it. Its partner setInterval() runs a function repeatedly, with the given interval between runs. Both return an identifier that you pass to clearTimeout() or clearInterval() to cancel the timer:

let polls = 0;

const timer = setInterval(() => {
  polls += 1;
  console.log(`Poll ${polls}: checking outage queue`);
  if (polls === 3) {
    clearInterval(timer);
    console.log("Stopped polling");
  }
}, 50);

const reminder = setTimeout(() => console.log("This never prints"), 1000);
clearTimeout(reminder);
Poll 1: checking outage queue
Poll 2: checking outage queue
Poll 3: checking outage queue
Stopped polling

Two details matter in practice:

  • The delay is a minimum, not a promise of exact timing. The callback is only queued as a task when the time is up, and it runs when the call stack is free. Browsers also slow down timers in background tabs and may enforce a minimum of about 4 milliseconds for deeply nested timers.
  • A program keeps running as long as timers are active. A Node.js script with a setInterval() that is never cleared never ends on its own.

The basics of setTimeout() and setInterval() as callbacks are also covered on the Functions page.

Callbacks and callback hell

The oldest way to get a result back from asynchronous work is a callback: you pass a function, and the slow operation calls it when it is done. Node.js built its original APIs on a convention called error-first callbacks: the callback's first argument is an error (or null when all went well), and the result follows.

// Node.js-style callbacks: the first argument is the error, or null.
function readMeter(meterId, callback) {
  setTimeout(() => {
    if (meterId.startsWith("KE-")) {
      callback(null, { meterId, kwh: 412 });
    } else {
      callback(new Error(`Unknown meter ${meterId}`));
    }
  }, 10);
}

readMeter("KE-1042", (error, reading) => {
  if (error) {
    console.log("Failed:", error.message);
    return;
  }
  console.log("Reading:", reading);
});

readMeter("XX-9", (error, reading) => {
  if (error) {
    console.log("Failed:", error.message);
    return;
  }
  console.log("Reading:", reading);
});
Reading: { meterId: 'KE-1042', kwh: 412 }
Failed: Unknown meter XX-9

This works, but it scales badly. When each step needs the result of the previous one, every step goes inside the callback of the one before it. The code drifts to the right and every level repeats the same error check:

readMeter("KE-1042", (error, reading) => {
  if (error) return report(error);
  calculateBill(reading, (error, bill) => {
    if (error) return report(error);
    createInvoice(bill, (error, invoice) => {
      if (error) return report(error);
      emailInvoice(invoice, (error) => {
        if (error) return report(error);
        console.log("Invoice sent");
      });
    });
  });
});

This shape is known as callback hell (or the pyramid of doom): deeply nested callbacks that are hard to read, hard to change, and easy to get wrong, for example by forgetting one error check or by calling a callback twice. Promises were added to the language in ECMAScript 2015 (ES2015) to fix exactly this.

Promises

A promise is an object that stands for the eventual result of an asynchronous operation. Instead of passing a callback into the slow function, you get a promise back from it, and you attach your reactions to that object. A promise is always in one of three states:

  • pending: the operation is still running;
  • fulfilled: it succeeded, and the promise holds a value;
  • rejected: it failed, and the promise holds a reason, normally an Error.

A promise that is fulfilled or rejected is settled. It settles only once: after that its state and value never change.

Promise states: a pending promise becomes fulfilled through resolve(value), after which then() callbacks run, or rejected through reject(error), after which catch() callbacks run. Both end states are settled and final. pending resolve(value) reject(error) fulfilled .then() runs rejected .catch() runs settled final, never changes again
A promise starts pending and settles exactly once, as fulfilled or rejected.

Creating a promise

Most promises you meet come from APIs such as fetch(). To wrap an older callback API yourself, use the Promise constructor. It takes a function that receives two functions, resolve and reject; call one of them when the work is done. Here is the meter reader again, now returning a promise:

function readMeter(meterId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (meterId.startsWith("KE-")) {
        resolve({ meterId, kwh: 412 });
      } else {
        reject(new Error(`Unknown meter ${meterId}`));
      }
    }, 10);
  });
}

const pending = readMeter("KE-1042");
console.log(pending);

pending.then((reading) => {
  console.log("Fulfilled with:", reading);
  console.log(pending);
});

readMeter("XX-9")
  .then((reading) => console.log("Never runs:", reading))
  .catch((error) => console.log("Rejected with:", error.message))
  .finally(() => console.log("Done with XX-9"));
Promise { <pending> }
Fulfilled with: { meterId: 'KE-1042', kwh: 412 }
Promise { { meterId: 'KE-1042', kwh: 412 } }
Rejected with: Unknown meter XX-9
Done with XX-9

Node.js prints a promise's state when you log it: Promise { <pending> } while it waits, and the value once it is fulfilled. The three methods for reacting are:

  • .then(onFulfilled) runs when the promise is fulfilled and receives the value;
  • .catch(onRejected) runs when it is rejected and receives the error;
  • .finally(onSettled) runs in both cases, for clean-up such as hiding a loading indicator.

Chaining

Every call to .then() returns a new promise, which settles with whatever your callback returns. If the callback returns another promise, the chain waits for it. That turns the nested pyramid into a flat list of steps, with one .catch() at the end for an error in any step:

function readMeter(meterId) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ meterId, kwh: 412 }), 10);
  });
}

function priceFor(kwh) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(kwh * 0.27), 10);
  });
}

readMeter("KE-1042")
  .then((reading) => {
    console.log("Step 1, reading:", reading.kwh, "kWh");
    return priceFor(reading.kwh); // a promise: the chain waits for it
  })
  .then((amount) => {
    console.log("Step 2, amount:", amount.toFixed(2));
    return amount > 100; // a plain value: passed on straight away
  })
  .then((needsReview) => {
    console.log("Step 3, needs review:", needsReview);
  })
  .catch((error) => {
    console.log("Any step failed:", error.message);
  });
Step 1, reading: 412 kWh
Step 2, amount: 111.24
Step 3, needs review: true

async and await

Chains are better than nested callbacks, but they are still not how people naturally write steps. ECMAScript 2017 added two keywords that let asynchronous code look synchronous.

An async function is a function declared with the async keyword. It always returns a promise: a value you return fulfills that promise, and an error you throw rejects it. Inside it, the await operator pauses the function until a promise settles, then gives you the fulfilled value, or throws the rejection reason as an exception. While the function is paused, the event loop runs other code; nothing is blocked.

function readMeter(meterId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (meterId.startsWith("KE-")) {
        resolve({ meterId, kwh: 412 });
      } else {
        reject(new Error(`Unknown meter ${meterId}`));
      }
    }, 10);
  });
}

async function monthlyBill(meterId) {
  try {
    const reading = await readMeter(meterId);
    const amount = reading.kwh * 0.27;
    return `${meterId}: EUR ${amount.toFixed(2)}`;
  } catch (error) {
    return `${meterId}: no bill (${error.message})`;
  }
}

async function main() {
  console.log(await monthlyBill("KE-1042"));
  console.log(await monthlyBill("XX-9"));
}

const result = main();
console.log("main() returned:", result);
main() returned: Promise { <pending> }
KE-1042: EUR 111.24
XX-9: no bill (Unknown meter XX-9)

Three things to notice. Errors from awaited promises are handled with ordinary try and catch (see Error Handling). The steps read top to bottom, like synchronous code. And main() itself returns a pending promise straight away; the line after the call prints first, because main() pauses at its first await.

Forgetting await

Calling an async function without await gives you the promise, not the value. JavaScript does not complain; you only notice when the value looks strange:

async function getTemperature() {
  return 18.5;
}

const forgotten = getTemperature();
console.log("Without await:", forgotten);
console.log("Plus one:", forgotten + 1);

async function main() {
  const temperature = await getTemperature();
  console.log("With await:", temperature);
  console.log("Plus one:", temperature + 1);
}

main();
Without await: Promise { 18.5 }
Plus one: [object Promise]1
With await: 18.5
Plus one: 19.5

forgotten + 1 turns the promise object into the string "[object Promise]" and appends 1. If you see [object Promise] or Promise { … } where you expected data, look for a missing await.

Where await is allowed

await works inside async functions. In a classic script or a CommonJS file, using it anywhere else is a SyntaxError. Since ECMAScript 2022 it also works at the top level of an ES module, such as a .mjs file in Node.js or a <script type="module"> in a browser:

// example.mjs — an ES module, so await works at the top level
const temperature = await Promise.resolve(18.5);
console.log("Temperature:", temperature);
Temperature: 18.5

Running tasks in parallel

await in a sequence makes each step wait for the previous one. That is right when a step needs the previous result, but wasteful when the steps are independent, such as reading three meters. Start all of them first, then wait for all of them with Promise.all(): a function that takes an array of promises and returns one promise, fulfilled with an array of all results (in the order of the input, not the order of finishing), or rejected as soon as any input promise rejects.

function readMeter(meterId, ms) {
  console.log(`  start ${meterId}`);
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`  done  ${meterId}`);
      resolve(ms * 10);
    }, ms);
  });
}

async function oneByOne() {
  const a = await readMeter("KE-1", 30);
  const b = await readMeter("KE-2", 10);
  const c = await readMeter("KE-3", 20);
  return [a, b, c];
}

async function allAtOnce() {
  return Promise.all([
    readMeter("KE-1", 30),
    readMeter("KE-2", 10),
    readMeter("KE-3", 20),
  ]);
}

async function main() {
  console.log("One by one (about 60 ms):");
  console.log(await oneByOne());
  console.log("All at once (about 30 ms):");
  console.log(await allAtOnce());
}

main();
One by one (about 60 ms):
  start KE-1
  done  KE-1
  start KE-2
  done  KE-2
  start KE-3
  done  KE-3
[ 300, 100, 200 ]
All at once (about 30 ms):
  start KE-1
  start KE-2
  start KE-3
  done  KE-2
  done  KE-3
  done  KE-1
[ 300, 100, 200 ]

One by one, each meter starts only after the previous one is done, so the waiting times add up. With Promise.all() all three start at once and finish in order of speed (KE-2 first), so the total wait is that of the slowest meter. The result array is the same in both cases. "Parallel" here means the waiting overlaps; your JavaScript code still runs on one thread.

allSettled, race and any

Promise.all() is all-or-nothing. When you want every outcome, for example in a status check of several services, use Promise.allSettled() (ES2020). It never rejects; it fulfills with one object per input, with status set to "fulfilled" (and a value) or "rejected" (and a reason):

function checkSite(name, ok) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (ok) resolve(`${name} is up`);
      else reject(new Error(`${name} is down`));
    }, 10);
  });
}

async function main() {
  const sites = [
    checkSite("portal", true),
    checkSite("billing", false),
    checkSite("meter-api", true),
  ];

  try {
    await Promise.all(sites);
  } catch (error) {
    console.log("Promise.all rejected:", error.message);
  }

  const results = await Promise.allSettled(sites);
  for (const result of results) {
    if (result.status === "fulfilled") {
      console.log("fulfilled:", result.value);
    } else {
      console.log("rejected: ", result.reason.message);
    }
  }
}

main();
Promise.all rejected: billing is down
fulfilled: portal is up
rejected:  billing is down
fulfilled: meter-api is up

Two more combinators settle on the first result. Promise.race() settles like the first input promise that settles, success or failure; it is a classic way to add a timeout. Promise.any() (ES2021) fulfills with the first success and only rejects when every input fails:

function delay(ms, value) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

function failAfter(ms, message) {
  return new Promise((_, reject) =>
    setTimeout(() => reject(new Error(message)), ms),
  );
}

async function main() {
  const fastest = await Promise.race([
    delay(30, "mirror A"),
    delay(10, "mirror B"),
  ]);
  console.log("race:", fastest);

  const firstSuccess = await Promise.any([
    failAfter(5, "mirror A offline"),
    delay(20, "mirror B"),
  ]);
  console.log("any: ", firstSuccess);

  try {
    await Promise.race([delay(50, "report"), failAfter(10, "Timed out")]);
  } catch (error) {
    console.log("race:", error.message);
  }
}

main();
race: mirror B
any:  mirror B
race: Timed out
Combinator Fulfills when Rejects when
Promise.all() all inputs fulfill (array of values) the first input rejects
Promise.allSettled() all inputs have settled (array of outcome objects) never
Promise.race() the first input to settle fulfills the first input to settle rejects
Promise.any() the first input fulfills all inputs reject (an AggregateError)

Errors and unhandled rejections

A rejected promise that nobody handles is an unhandled rejection: a promise that is rejected without any .catch(), second argument of .then(), or surrounding try/await to receive the error. Browsers log it in the console as "Uncaught (in promise)". Node.js, since version 15, treats it like an uncaught exception and stops the whole program:

function saveReading(kwh) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (kwh < 0) reject(new Error(`Negative reading: ${kwh}`));
      else resolve(kwh);
    }, 10);
  });
}

saveReading(412).then(() => console.log("Saved 412"));
saveReading(-5).then(() => console.log("Saved -5"));
console.log("Requests sent");
Requests sent
Saved 412
Error: Negative reading: -5

The fix is to handle every promise somewhere: put await calls in a try block, end every chain with .catch(), and give the top-level call of an async program its own handler, for example main().catch((error) => { console.error(error); process.exitCode = 1; });. The Error Handling page covers reading and throwing errors in more depth.

Async functions in loops

Array methods such as forEach() and map() do not know about promises. forEach() calls your async callback, ignores the promise it returns and moves on, so the surrounding code does not wait:

const readings = [412, 398, 455];

function save(kwh) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("saved", kwh);
      resolve();
    }, 10);
  });
}

async function wrong() {
  readings.forEach(async (kwh) => {
    await save(kwh);
  });
  console.log("wrong: finished?");
}

async function right() {
  for (const kwh of readings) {
    await save(kwh);
  }
  console.log("right: finished");
}

async function main() {
  await wrong();
  await new Promise((resolve) => setTimeout(resolve, 50));
  await right();
}

main();
wrong: finished?
saved 412
saved 398
saved 455
saved 412
saved 398
saved 455
right: finished

wrong() reports that it has finished before a single reading is saved. Use a for...of loop with await when the steps must happen one after another, or await Promise.all(readings.map(save)) when they may run together.