Promises and async/await in JavaScript
By the end of this page you will be able to read an async function without losing the thread, run three requests at once instead of one after another, and put a try block in the one position where it actually catches the failure.
You need Node installed to run the examples, and nothing else. Save each snippet as a file such as app.js next to a package.json containing { "type": "module" }, then run node app.js.
What a Promise Actually Is
A promise is an object standing in for work that has not finished yet, and sometimes for work that already has. It has three states. It starts pending, and then it either fulfils with a value or rejects with a reason, once and permanently.
You can watch two of the three in one run. This example makes a promise that settles after a tenth of a second, then logs the object before and after:
const slowValue = new Promise((resolve) => {
setTimeout(() => resolve("done"), 100);
});
console.log(slowValue);
slowValue.then((value) => {
console.log("settled with", value, slowValue);
}); // node output:
// Promise { <pending> }
// settled with done Promise { 'done' } The first line printed while the value was still missing. That gap is the whole point of a promise, and it is also why logging a function call directly so often prints Promise { <pending> } instead of the answer you expected.
The word settled covers both endings. A settled promise never changes again, so calling resolve twice does nothing the second time, and a rejection cannot be talked back into a value.
Most of the time you will not write new Promise at all, because browser and Node APIs hand you promises already made. Its job is to build a promise around something that does not produce one, and the most common case is an older function that takes a callback:
import { readFile } from "node:fs";
function readFilePromise(path) {
return new Promise((resolve, reject) => {
readFile(path, "utf8", (error, contents) => {
if (error) reject(error);
else resolve(contents);
});
});
}
console.log(await readFilePromise("./package.json"));
try {
await readFilePromise("./missing.json");
} catch (error) {
console.log("rejected with:", error.code);
} // node output:
// { "type": "module" }
//
// rejected with: ENOENT That is the whole pattern. Call resolve where the old code succeeded, call reject where it failed, and everything downstream can use await.
You rarely need to write it by hand for Node's own APIs. Many of them ship a promise version, which for the file system lives at node:fs/promises. Plenty of others stay event-based or stream-based, and those still want a wrapper or a listener.
Then, Catch, and Finally
Before async existed, you read the value out of a promise by handing it a callback. That style is still everywhere in working code, so it is worth being able to read:
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
delay(50, "first")
.then((value) => {
console.log("then got", value);
return value.toUpperCase();
})
.then((value) => console.log("second then", value))
.catch((error) => console.log("never runs here", error)); // node output:
// then got first
// second then FIRST Each then returns a new promise, which is why they chain. Whatever you return from one callback becomes the value the next one receives, and returning a promise from inside a then waits for it rather than nesting it.
Failures work the same way in reverse. A rejection skips every then that only handles success, until something offers to handle it, and finally runs either way:
function failAfter(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("boom")), ms);
});
}
failAfter(10)
.then(() => console.log("skipped"))
.catch((error) => console.log("caught:", error.message))
.finally(() => console.log("finally ran")); // node output:
// caught: boom
// finally ran Note that finally receives no argument. It is for cleanup that has to happen either way, such as hiding a spinner, not for inspecting what happened.
catch is not the only place a rejection can be handled. A then takes a second callback for exactly that, and a handler that returns normally puts the chain back on the success path:
failAfter(10)
.then(
(value) => console.log("skipped", value),
(error) => {
console.log("handled by the second argument:", error.message);
return "recovered";
},
)
.then((value) => console.log("next then still runs, with:", value))
.catch(() => console.log("never reached")); // node output:
// handled by the second argument: boom
// next then still runs, with: recovered So a rejection travels past handlers that only deal with success, and stops at the first one willing to take it. Recovering is a real option, not just reporting.
Async Functions and Await
The async and await keywords let you write the same thing as ordinary top-to-bottom code. Put async on the function and await in front of any promise:
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function loadReport() {
const owner = await delay(50, "Dana");
const title = await delay(50, "Weekly numbers");
return `${title}, by ${owner}`;
}
console.log(await loadReport()); // node output:
// Weekly numbers, by Dana Two rules make this behave predictably. An async function always returns a promise, whatever its body returns, and await suspends only the async function it appears in, or the module when it is used at the top level.
That second rule matters more than it sounds. The rest of the program carries on while an async function is paused, which is exactly how two calls started one after the other end up overlapping.
You can use await at the top level of an ES module, as the example above does. Inside a plain function that is not marked async, it is a syntax error.
Two smaller behaviours round the picture out. The first: awaiting something that is not a promise still gives you the value, and still hands control back before resuming.
async function readValue() {
console.log("1: inside, before the await");
const value = await 42;
console.log("3: await gave back", value);
}
readValue();
console.log("2: the line after the call"); // node output:
// 1: inside, before the await
// 2: the line after the call
// 3: await gave back 42 Line three came last even though 42 was sitting right there. await always suspends, whatever it is given, which is why a helper can return either a value or a promise without its callers needing to know.
The second: a promise you deliberately do not wait for still needs a catch, or its failure has no owner.
async function trackView() {
throw new Error("analytics endpoint refused");
}
trackView().catch((error) => console.log("logged and moved on:", error.message));
console.log("the page carried on rendering"); // node output:
// the page carried on rendering
// logged and moved on: analytics endpoint refused This is the fire-and-forget pattern, and the catch on the end is not optional. It is the only thing that owns a failure nobody is waiting for.
The rendering line printed first because the catch callback is queued rather than run on the spot. Nothing waits for the tracking call, so the page carries on and the report arrives afterwards.
Sequential Work Against Promise.all
Awaiting one line after another is correct when each step needs the one before it. When the steps are independent, it wastes the wait. This example times both shapes with the same three tasks:
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
console.time("sequential");
const a = await delay(300, "a");
const b = await delay(300, "b");
const c = await delay(300, "c");
console.timeEnd("sequential");
console.time("overlapping");
const all = await Promise.all([delay(300, "a"), delay(300, "b"), delay(300, "c")]);
console.timeEnd("overlapping");
console.log(all); // node output, give or take a few milliseconds:
// sequential: 906.855ms
// overlapping: 302.246ms
// [ 'a', 'b', 'c' ] Three hundred milliseconds against nine hundred, for identical work. The three waits overlapped, which is not the same as running on three processors: Promise.all does not start anything, it watches promises that are already running.
It gives back an array of results in the same order you passed them, regardless of which finished first.
The same waste hides inside loops, where it is much harder to spot. Awaiting inside for makes each pass wait for the last one:
const ids = [1, 2, 3, 4];
console.time("loop");
const results = [];
for (const id of ids) {
results.push(await delay(200, id));
}
console.timeEnd("loop");
console.time("overlapping");
const faster = await Promise.all(ids.map((id) => delay(200, id)));
console.timeEnd("overlapping"); // node output:
// loop: 807.979ms
// overlapping: 201.143ms Reach for the loop deliberately, not by habit. Sequential is right when each request needs the previous answer, and when a server would rate-limit you for firing everything at once.
One caution on Promise.all: it rejects as soon as any single promise rejects, and it does not cancel the others, so their work and their side effects carry on. That is right for all-or-nothing work and wrong for a dashboard, where one dead panel should not blank the other five.
Promise.allSettled is the version for that case. A rejected input never rejects it, and it hands back a description of each outcome rather than the values directly:
function failAfter(ms, message) {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error(message)), ms);
});
}
const results = await Promise.allSettled([
delay(10, "prices"),
failAfter(10, "the news panel is down"),
delay(20, "orders"),
]);
for (const result of results) {
if (result.status === "fulfilled") console.log("loaded:", result.value);
else console.log("failed:", result.reason.message);
} // node output:
// loaded: prices
// failed: the news panel is down
// loaded: orders Each entry carries a status, plus a value when it fulfilled or a reason when it rejected. The array is still in the order you passed it, so entry two belongs to the second request whatever happened to it.
Two more combinators exist and you will meet them in other people's code. Neither waits for all of the inputs, and they disagree about what they are waiting for:
console.log(await Promise.race([delay(50, "the mirror"), delay(200, "the main server")]));
try {
await Promise.race([failAfter(10, "the mirror is down"), delay(50, "the main server")]);
} catch (error) {
console.log("race rejected:", error.message);
}
console.log(await Promise.any([failAfter(10, "the mirror is down"), delay(50, "the main server")])); // node output:
// the mirror
// race rejected: the mirror is down
// the main server Promise.race mirrors whichever input settles first, so a fast failure wins. Promise.any ignores rejections until something fulfils, and rejects with an AggregateError only once every input has failed.
Four names, one question each. Do you need all of them, all of their outcomes, whatever settles first, or the first one that works.
Where Errors Go
Inside an async function, a rejected promise behaves like a thrown error, so ordinary try and catch work. The error travels up through every awaiting caller until something catches it:
async function loadUser() {
throw new Error("user service is down");
}
async function loadProfile() {
const user = await loadUser();
return user.name;
}
try {
await loadProfile();
} catch (error) {
console.log("caught at the top:", error.message);
} // node output:
// caught at the top: user service is down Nothing in loadProfile handled anything, and the message still arrived. Throwing inside an async function is the same act as returning a rejected promise, which is why the two styles interoperate.
Now the version that catches nothing, and looks almost identical. The only difference is a missing await:
async function saveDraft() {
throw new Error("disk is full");
}
function save() {
try {
saveDraft();
console.log("saved");
} catch (error) {
console.log("could not save:", error.message);
}
}
save(); // node output:
// saved
// ...then the process crashes with the original error:
// Error: disk is full
// at saveDraft (file:///.../app.js:2:9) It reported success. The call returned a promise immediately, the try block finished with nothing to catch, and the rejection surfaced afterwards with no owner.
The rule that prevents this: try must wrap the await, not the call. A promise you never await and never catch is an unhandled rejection. Node's default is to turn that into an uncaught exception, which ends the process, though flags and a rejection handler can change it. Browsers report it and carry on.
There is a middle position between catching and ignoring, and it is usually the right one. Catch the failure where you know something useful about it, add that context, and throw again so the caller still finds out:
async function loadPrices() {
try {
throw new Error("connection reset");
} catch (error) {
throw new Error("could not load prices", { cause: error });
}
}
try {
await loadPrices();
} catch (error) {
console.log(error.message);
console.log("caused by:", error.cause.message);
} // node output:
// could not load prices
// caused by: connection reset The cause option keeps the original error attached instead of replacing it. That matters because the readable message and the diagnostic detail are usually two different things, and a rewritten error that drops the original throws away the half you need at three in the morning.
Catch where you can act, and let everything else travel. A catch block that logs and then carries on regardless is how a failure becomes an empty screen, with the only trace of it sitting in a console nobody opened.
Pitfalls and Debugging
The console prints Promise { <pending> }. You logged the call rather than its result. Add await in front of it, inside an async function or at the top level of a module.
An unhandled rejection ends the Node process with code 1. Something rejected with nobody watching. Node prints the original error and its stack, so read that, then find the call that has no await and no .catch.
SyntaxError: await is only valid in async functions. The await is inside a plain function, and very often that function is a callback you passed to something else. Mark that callback async rather than the outer function.
That fixes the syntax only. If the API ignores the promise the callback now returns, as forEach does, nothing waits for it and the rejection still needs an owner.
An await inside forEach that does not wait. forEach ignores the promise its callback returns, so the loop finishes instantly and the work runs unsupervised. Use for...of when you want each step to wait, or Promise.all with map when you do not.
A catch block that reports a useless error. Log the whole error object rather than just its message, so the stack survives, and check error.cause as well. cause is optional and holds whatever the thrower put there, but when it is present it usually holds the real problem.
Everything works but takes far too long. Wrap the suspect section in console.time and console.timeEnd as the examples above do. A total that is a neat multiple of one request's duration suggests the calls are running one at a time, though retries, queues, and rate limits can look the same.
Mixing await with a then chain on the same promise. It works, but nobody can read it. Pick one style per function.
Frequently Asked Questions
Should you use then or await?
Use await for anything you would otherwise read top to bottom, because the result is a straight line of code with ordinary try/catch around it. Reach for then when you are attaching a small follow-up to a promise you are not waiting on, such as a background log. End that chain with a catch, because then alone owns no rejection.
Does await block the whole page?
No. Inside a function it suspends that async function, and at the top level of a module it suspends that module and anything importing it. Either way the rest of the program keeps running, which is why two async functions started one after the other can overlap. What does block a page is a long synchronous loop.
When should you use Promise.allSettled instead of Promise.all?
Use allSettled when one failure should not cancel your interest in the others, such as loading six independent dashboard panels. A rejected input does not reject it: you get an array describing each result as fulfilled or rejected. Promise.all is right when the work is all-or-nothing and a single failure makes the rest useless.
Why does my async function return a promise instead of a value?
Because that is what async means. Every async function returns a promise, even one whose body has no await and returns a plain number. The caller has to await it or attach a then. Logging the call without either is the most common way people end up printing Promise { pending } to the console.
Related
- JavaScript Async for the full topic overview
- Fetch, JSON, and the Network for the promises you will meet most often in practice
- Timers, Ordering, and Race Conditions for what happens when two of these calls overlap
- Functions, Callbacks, and Closures for the callbacks
thentakes and the state they capture - JavaScript for the language guide and the wider learning path
Sources
-
[1]
Using Promises(developer.mozilla.org)
-
[2]
Promise(developer.mozilla.org)
-
[3]
async function(developer.mozilla.org)
Read Next
Requesting data with fetch, the response.ok check most tutorials skip, parsing JSON, sending data with POST, telling a bad answer from no usable answer, and timeouts with AbortController.
setTimeout and setInterval, the order callbacks actually print in, the stale response race that breaks search boxes, debounce and throttle, and cleaning up when the owner disappears.
The JavaScript that runs later: promises and await, fetching data over the network, and the ordering rules behind timers and race conditions.