Promises were the answer to callback hell — deeply nested callbacks that made asynchronous JavaScript unreadable and error-prone. They shipped in ES6 (2015), became the foundation of async/await in ES2017, and are now the bedrock of every browser API and Node.js core module. A decade in, they are still misused in subtle ways that cause real production bugs.
What changed in 2026
Promise.withResolvers() is now widely available. Standardised in ES2024, this static method replaces the manual executor pattern for creating resolvable promises externally.
- Node 22+ makes unhandled rejections a hard crash by default. The
--unhandled-rejections=throw flag is now the default. Any unhandled rejection terminates the process.
- Temporal API ships in browsers.
Temporal.Now.instant() and date parsing return Promises or synchronous values with well-defined async wrappers — promise error handling applies everywhere.
- React 19
use(promise) hook. A promise can now be passed directly to use() inside a component, with Suspense and error boundaries handling the states declaratively.
The three states
A Promise is always in exactly one state:
| State |
Meaning |
Can transition to |
| Pending |
Operation in flight |
Fulfilled or Rejected |
| Fulfilled |
Operation succeeded, value available |
— (terminal) |
| Rejected |
Operation failed, reason available |
— (terminal) |
State transitions are permanent. A fulfilled promise cannot become rejected, and vice versa.
const p = new Promise((resolve, reject) => {
// Pending here
setTimeout(() => resolve(42), 1000); // → Fulfilled with 42
// Or: reject(new Error("failed")) // → Rejected with Error
});
Chaining with .then() and .catch()
.then(onFulfilled, onRejected) returns a new promise. This enables chaining without nesting.
fetch('/api/user/1')
.then(res => res.json()) // parse JSON
.then(user => fetchOrders(user.id)) // fetch related data
.then(orders => console.log(orders))
.catch(err => console.error('Failed:', err)); // catches any rejection above
Each .then() transforms the value. A .catch() at the end catches any rejection anywhere in the chain.
// Promise.withResolvers — ES2024
const { promise, resolve, reject } = Promise.withResolvers();
// Resolve or reject externally
setTimeout(() => resolve('done'), 1000);
await promise;
Concurrent execution patterns
| Method |
Behaviour |
Use when |
Promise.all(arr) |
Fulfils when all fulfil; rejects on first rejection |
All-or-nothing operations |
Promise.allSettled(arr) |
Always fulfils with array of outcomes |
Need all results regardless of failures |
Promise.race(arr) |
Settles with the first settled promise |
Timeout patterns, fastest-wins |
Promise.any(arr) |
Fulfils with first fulfilled; rejects if all reject |
First-success-wins (fallback chains) |
// Run three requests in parallel; fail if any fails
const [user, orders, prefs] = await Promise.all([
fetchUser(id),
fetchOrders(id),
fetchPrefs(id),
]);
// Run three requests; collect all outcomes
const results = await Promise.allSettled([
fetchUser(id),
fetchOrders(id),
fetchPrefs(id),
]);
results.forEach(r => {
if (r.status === 'fulfilled') console.log(r.value);
else console.error(r.reason);
});
Relationship to async/await
async/await is syntactic sugar over Promises. An async function always returns a Promise; await pauses execution until the awaited Promise settles.
// Equivalent: .then() chain
function loadUser(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json())
.catch(err => { throw new Error(`User load failed: ${err.message}`); });
}
// Equivalent: async/await
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
return await res.json();
} catch (err) {
throw new Error(`User load failed: ${err.message}`);
}
}
Both produce the same promise chain under the hood. async/await reads more linearly, especially with error handling.
Common mistakes
Forgetting to return the Promise inside .then().
// Bug: the inner fetch is not returned; chain does not wait for it
.then(user => { fetch(`/api/orders/${user.id}`); }) // returns undefined
// Fix: return the promise
.then(user => fetch(`/api/orders/${user.id}`))
Not handling rejections.
// Node 22+: this crashes the process
somePromise.then(doSomething); // no .catch()
// Fix:
somePromise.then(doSomething).catch(handleError);
// Or use async/await with try/catch
await inside a .forEach() loop. Array.forEach does not await async callbacks — the loop completes immediately.
// Bug: all requests fire simultaneously, errors are swallowed
items.forEach(async (item) => { await process(item); });
// Fix: use for...of
for (const item of items) { await process(item); }
// Or, for parallel: await Promise.all(items.map(process));
Mixing .then() chains and await in the same function. It works but confuses readers. Pick one style.
Error handling comparison
| Pattern |
Catches synchronous errors |
Catches async rejections |
Readable |
.then().catch() |
No (need try inside .then) |
Yes |
Medium |
async/await + try/catch |
Yes |
Yes |
High |
Global unhandledRejection handler |
Yes (fallback only) |
Yes (fallback only) |
N/A — last resort |
How to pick
- Need to run multiple promises in parallel? →
Promise.all or Promise.allSettled.
- Timeout pattern? →
Promise.race([operation, timeout]).
- Sequential async steps with error handling? →
async/await + try/catch.
- Streaming chain transformations? →
.then() chains work well for pipeline-style code.
- Exposing a promise to external code for manual resolution? →
Promise.withResolvers().
What to skip
- Callback-based wrappers when Promise APIs exist —
util.promisify is there if you need it; prefer the native promise API.
async functions that never await anything — they add wrapping overhead for no benefit; return a plain value or a plain promise.
- Deeply nested
.then() chains — if you find yourself indenting .then() inside .then(), use async/await. See Async/await explained in 2026 for the full treatment.
FAQ
Can a Promise be cancelled?
No — the native Promise API has no cancellation. Use AbortController with fetch and other supporting APIs, or the AbortSignal pattern for custom async operations.
What happens if both resolve and reject are called?
Only the first call takes effect. After the first transition, subsequent calls to resolve or reject are silently ignored.
Is Promise.resolve(value) the same as new Promise(res => res(value))?
Functionally yes, but Promise.resolve is more efficient and handles the case where value is itself a promise (it returns it directly instead of wrapping it).
Why does .catch(fn) work the same as .then(undefined, fn)?
Because .catch(fn) is literally defined as .then(undefined, fn). The only functional difference is that .then(onFulfilled, onRejected) does not catch errors thrown by onFulfilled in the same call, while chained .then().catch() does.
Where to go next