A promise in JavaScript is an object that represents a value you do not have yet: the eventual result of an asynchronous operation such as a network request, a file read, or a timer. Instead of blocking while the work runs, you get a placeholder you can attach callbacks to, and the placeholder resolves with a value (or an error) once the work finishes. In 2026 you rarely write .then() chains by hand anymore; async/await is the default style, and it is built directly on promises. This guide covers both, plus the two mistakes I see most often.
The three states (and "settled")
Every promise is in exactly one of three states:
- pending: the work has not finished. This is the starting state.
- fulfilled: the work finished successfully and the promise has a value.
- rejected: the work failed and the promise has a reason (usually an
Error).
A promise that is no longer pending (fulfilled or rejected) is called settled. The transition is one-way and one-time: a pending promise settles exactly once, and after that its state and value never change. You cannot un-reject a promise or resolve it twice; the second attempt is silently ignored. That immutability is the whole point. Once you hold a settled promise, reading it is safe forever.
.then, .catch, .finally
You read a promise's eventual value with three methods:
fetch("https://api.example.com/user/42")
.then((response) => response.json()) // runs on fulfilment, returns the next promise
.then((user) => console.log(user.name))
.catch((error) => console.error("request failed:", error)) // runs on any rejection above
.finally(() => console.log("done, success or not")); // always runs, gets no valueThree things worth internalising:
.then()returns a new promise. Whatever youreturnfrom inside it becomes the value the next.then()receives. Return a promise (likeresponse.json()) and the chain waits for it to settle before continuing. This is how you sequence asynchronous steps without nesting..catch(fn)is shorthand for.then(undefined, fn). A single.catch()at the end of a chain handles a rejection from any step above it, because a rejection skips every.then()until it finds a rejection handler. One catch covers the whole chain..finally()runs on both outcomes and receives no argument. Use it for cleanup (hide a spinner, close a handle). It does not swallow a rejection: if the promise rejected, it still rejects after.finally()runs, so a downstream.catch()is still required..finally()is for side effects, not error handling.
async/await is the default now
The chain above reads top-to-bottom once you rewrite it with async/await, which is the syntax I reach for first in 2026:
async function teLoadUser(id) {
const response = await fetch(`https://api.example.com/user/${id}`);
const user = await response.json();
return user.name;
}await pauses the function until the promise settles, then unwraps its value (or throws its rejection). It does not block the thread; JavaScript is single-threaded, and while one async function is suspended at an await, the event loop keeps doing other work. Two facts that surprise people:
- An
asyncfunction always returns a promise. Evenasync function f() { return 1; }returns a promise that fulfils with1, never the bare1. If youreturna promise from an async function, it is not double-wrapped; the outer promise adopts the inner one's eventual value. - You can
awaita non-promise.await 5just gives you5.awaitwraps its operand inPromise.resolve(), so awaiting a value-or-promise always works. That is why you almost never need to check whether something is a promise before awaiting it.
Error handling: try/catch around await
With .then() chains you handle errors in .catch(). With await, you use ordinary try/catch, which is one of its biggest wins: synchronous and asynchronous errors land in the same block.
async function teLoadUser(id) {
try {
const response = await fetch(`https://api.example.com/user/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`); // fetch does NOT reject on 4xx/5xx
}
return await response.json();
} catch (error) {
console.error("could not load user:", error);
return null; // or rethrow, depending on the caller
}
}Note the return await response.json() inside the try: if you write return response.json() (no await), the function returns before the JSON promise settles, so a rejection from parsing escapes the try/catch and becomes an unhandled rejection in the caller. Inside a try block, return await is the safe form.
One thing to take seriously: an unhandled rejection crashes a modern Node process. Since Node 15 the default behaviour is --unhandled-rejections=throw, so a promise that rejects with no .catch() and no try/catch terminates the program. A floating fetch() with no error handling is a latent crash, not a warning. See the common promise mistakes that bite in production for the full list, floating promises included.
Don't wrap something that already returns a promise
This is the antipattern I correct most often. The new Promise(...) constructor exists to adopt a callback-based API into the promise world. It is the wrong tool when the thing you are wrapping already returns a promise.
// Antipattern: pointless wrapper around an already-async call
function teGetUser(id) {
return new Promise((resolve, reject) => {
fetch(`/user/${id}`)
.then((res) => resolve(res))
.catch((err) => reject(err));
});
}
// Correct: fetch already returns a promise. Just return it.
function teGetUser(id) {
return fetch(`/user/${id}`);
}The wrapper adds nothing but a layer where errors can leak (forget the .catch and the rejection is swallowed) and a new place for bugs. Reserve new Promise for genuinely callback-based primitives that have no promise interface, like setTimeout or an old-style event:
// Legitimate use: setTimeout has no promise interface, so we build one.
const teSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await teSleep(1000);(In Node you do not even need that wrapper: import { setTimeout } from "node:timers/promises" gives you an awaitable await setTimeout(1000) directly. More patterns in how to sleep, wait, and poll in JavaScript.)
The await-in-a-loop trap: sequential vs parallel
This is the single most common async/await performance bug. Awaiting inside a for loop runs the operations one after another, each waiting for the previous to finish:
// SLOW: each fetch waits for the one before it. Three 200ms requests = ~600ms.
async function teLoadAllSequential(ids) {
const users = [];
for (const id of ids) {
users.push(await fetch(`/user/${id}`).then((r) => r.json()));
}
return users;
}If the requests do not depend on each other, that serialization is wasted time. Kick them all off first, then await them together with Promise.all:
// FAST: all three fetches start at once. Total time = the slowest one, ~200ms.
async function teLoadAllParallel(ids) {
const requests = ids.map((id) => fetch(`/user/${id}`).then((r) => r.json()));
return Promise.all(requests); // resolves to an array of results, in input order
}The trap is subtle because both versions are correct; one is just three times slower. The rule of thumb: if iteration n does not need the result of iteration n-1, do not await inside the loop. Build an array of promises and hand it to a combinator. Promise.all rejects as soon as any one input rejects (fail-fast); when you want every result regardless of individual failures, reach for Promise.allSettled. The full decision guide is in Promise.all vs allSettled vs race vs any.
A related caution: Array.prototype.forEach does not await an async callback. arr.forEach(async (x) => { await something(x); }) fires every callback and moves on without waiting for any of them. Use for...of with await (sequential) or Promise.all(arr.map(...)) (parallel) instead.
Top-level await in ES modules
You used to need an async wrapper just to use await at the top of a file. Since ES2022 you can await directly at module top level, with no enclosing function, in any ES module (a .mjs file, or a .js file in a package with "type": "module"):
// top of an ES module, no async wrapper needed
const config = await fetch("/config.json").then((r) => r.json());
export const apiBase = config.apiBase;This works in Node (14.8+) and every current browser. It is genuinely useful for module initialisation: loading config, opening a database connection, or dynamically importing a module based on a runtime condition. Two caveats. It only works in ES modules, not CommonJS (require), and a slow top-level await delays every module that imports yours, so keep it for real initialisation, not arbitrary work.
A note for Node specifically: fetch is a global since Node 18, so the examples above run server-side with no node-fetch install. It landed experimentally in 18 and was marked stable in Node 21. If you are on older Node, you still need the package, but for anything current, fetch just works. For the request and response model in depth, see the practical fetch() guide, and for cancelling or timing out a request, adding a timeout to fetch with AbortController.
FAQ
See also
- Promise.all vs allSettled vs race vs any: which combinator to reach for when you have several promises, and the gotcha that
allSettlednever rejects. - Common JavaScript promise mistakes: floating promises, swallowed errors, the unhandled-rejection crash, and how to fix each one.
- The practical fetch() guide: the request and response model, why
fetchdoes not reject on 4xx, and theresponse.okcheck. - Add a timeout to fetch with AbortController: cancelling and timing out a request with
AbortSignal.timeout(), in the browser and in Node. - How to sleep, wait, and poll in JavaScript: why there is no blocking
sleep(), the promise-based delay, and a polling loop done right.
Sources
Authoritative references this article was fact-checked against.
- Promise — MDN Web Docsdeveloper.mozilla.org
- Using promises — MDN Web Docsdeveloper.mozilla.org
- async function — MDN Web Docsdeveloper.mozilla.org
- await — MDN Web Docsdeveloper.mozilla.org
- Promise Objects — ECMAScript Language Specification (TC39)tc39.es





