Asynchronous JavaScript: Callbacks, Promises and async/await
How JavaScript waits without blocking: the event loop, callbacks, promises and their states, async and await, running tasks in parallel with Promise.all, and timers.
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.
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.
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.