How Promises and async/await Actually Work
Promise states, chaining, adoption, error propagation and microtask timing, then what async/await compiles to. With the ordering rules and the mistakes they explain.
- 8 minit bacaan
Halaman ini belum tersedia dalam Bahasa Melayu, jadi anda sedang membaca versi asal bahasa Inggeris.
A promise is a placeholder for a result that isn’t ready yet. It settles once, to a value or an error, and anyone who calls then on it gets notified, always asynchronously, as a microtask. async/await doesn’t replace promises; it is syntax that writes promise chains for you. This post builds the model from the ground up: states, chaining, how values and errors flow, exactly when callbacks run, and what an async function really does. Every ordering shown was run to confirm the result.
Why It Matters
Most async bugs come from a wrong mental model, not wrong syntax:
- a forgotten
awaitthat lets an error escape - a
forEachwithasynccallbacks that nobody waits for - requests that run one by one when they could run together
- a
try/catchthat doesn’t catch
All of them follow from a handful of rules.
How We Got Here: From Callbacks to async/await
Before promises, asynchronous JavaScript meant callbacks: you passed a function, and the API called it when the work finished. Node.js standardized “error-first” callbacks, where the first argument is an error or null. Loading a user, then their orders, then an invoice looked like this:
getUser(id, (error, user) => {
if (error) return handle(error);
getOrders(user.id, (error, orders) => {
if (error) return handle(error);
getInvoice(orders[0].id, (error, invoice) => {
if (error) return handle(error);
render(user, orders, invoice);
});
});
});This shape got a name, callback hell (or the “pyramid of doom”), and the indentation was the smallest problem:
- Error handling is repeated at every level, and one forgotten
if (error)silently swallows a failure. Athrowinside a callback can’t be caught by atryaround the outer call. - Inversion of control. You hand your continuation to someone else’s code and trust it to call it exactly once, with the right arguments. Buggy libraries called callbacks twice, never, or sometimes synchronously and sometimes not.
- No composition. “Run these three in parallel and continue when all finish”, “give up after 5 seconds” or “retry twice” each needed hand-written counters and flags.
Promises fixed this by turning “a result that will exist later” into a value you can return, pass around and combine. The same flow as a promise chain:
getUser(id)
.then((user) => getOrders(user.id).then((orders) => ({ user, orders })))
.then(({ user, orders }) =>
getInvoice(orders[0].id).then((invoice) => render(user, orders, invoice)),
)
.catch(handle); // one place for every error in the chainThe pyramid is gone and errors flow to one catch, but passing earlier results along is still awkward. async/await (ES2017) lets you write the same chain as straight-line code:
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const invoice = await getInvoice(orders[0].id);
render(user, orders, invoice);
} catch (error) {
handle(error);
}- Callbacks: nested, error handling at every level
- Promises/A+ (2012): community spec, libraries such as Q and Bluebird
- ES2015: native
Promise, chains with onecatch - ES2017:
async/await, the same promises written as plain code
Each step is built on the one before: await is promise chaining with nicer syntax, and promises still call callbacks under the hood. Old callback APIs can join in:
- Node.js’s
util.promisifywraps an error-first function so it returns a promise. - Most Node.js core modules now have promise versions (
fs/promises,timers/promises).
A Quick Event Loop Refresher
Promises don’t add threads. Everything below happens on the same single thread, scheduled by the event loop:
- Your synchronous code runs first, to completion.
- Promise callbacks and the code after each
awaitare microtasks. They run as soon as the current code finishes, all of them, before anything else. - Timers, clicks and network events are tasks. One runs per turn of the loop, after the microtasks are drained, and the browser may paint in between.
That is why a resolved promise’s then always runs before a setTimeout(…, 0) queued earlier. The event loop post covers the full model, with diagrams and ordering exercises.
States and Settling
A promise is in one of three states, and it can only move out of pending once:
- Pending: no result yet
- Fulfilled with a value, or rejected with a reason
- Settled for good: callbacks registered later still get the same result
const p = new Promise((resolve, reject) => {
resolve('first');
resolve('second'); // ignored
reject(new Error('too late')); // ignored
});The executor function runs synchronously inside new Promise. Only the reactions (then, catch, finally callbacks) run later.
then Returns a New Promise
Every then creates a new promise, settled by what its callback does:
Inside the then callback | The promise returned by then |
|---|---|
| Return a value | Fulfills with that value |
| Return a promise (or any “thenable”) | Adopts it: settles the same way, later |
| Throw an error | Rejects with that error |
| Nothing (no callback for this state) | Passes the value or error through unchanged |
That last row is how errors skip ahead to the next catch:
Promise.resolve(1)
.then((v) => {
throw new Error('x' + v);
})
.then(() => 'skipped') // no rejection handler here, so the error passes through
.catch((e) => e.message) // 'x1': catch returns a value, so the chain recovers
.then((v) => v + '!'); // 'x1!'When Callbacks Run
Reactions always run as microtasks, after the current synchronous code, even if the promise is already settled. There is one subtle cost: when a callback or an async function returns a promise, adopting it takes extra microtask turns:
async function returnsPromise() {
return Promise.resolve('f');
}
async function returnsValue() {
return 'g';
}
returnsPromise().then(log);
returnsValue().then(log);
Promise.resolve()
.then(() => log('p1'))
.then(() => log('p2'))
.then(() => log('p3'));
// g, p1, p2, f, p3'g'arrives first because returning a plain value fulfills immediately.'f'waits for the inner promise’sthento be called and to fire, which costs two extra turns.- Why it matters: you will rarely depend on this, but it explains why “equivalent” code can log in a different order, and why tests that assume a fixed number of ticks are fragile.
async/await Is Syntax Over Promises
An async function always returns a promise. Its body runs synchronously until the first await; the rest runs later, as a microtask, once the awaited value settles:
async function load() {
console.log(1);
const data = await fetchData(); // pause here; the caller continues
console.log(3, data);
return data.id; // fulfills the returned promise
}
load();
console.log(2);
// 1, 2, then 3 once fetchData settlesasync/await | What it means in promise terms |
|---|---|
async function f() { return x } | Returns a promise fulfilled with x |
throw err inside an async function | Returns a promise rejected with err |
const v = await p | p.then((v) => { …rest of the function… }) |
try { await p } catch (e) {} | The rejection of p is thrown at the await line |
The Mistakes This Model Explains
Sequential When It Could Be Parallel
// About 90 ms: each request waits for the previous one
for (const id of ids) results.push(await fetchUser(id));
// About 30 ms: all start at once
const results = await Promise.all(ids.map(fetchUser));Promise.allfor independent workPromise.allSettledwhen you need every outcome, even if some failPromise.anyfor the first successPromise.racefor the first to settle
For hundreds of items, limit concurrency so you don’t overload a server (see Limiting Concurrency).
return Without await Inside try
async function noAwait() {
try {
return Promise.reject(new Error('lost'));
} catch {
return 'caught'; // never runs: the promise is returned before it rejects
}
}
async function withAwait() {
try {
return await Promise.reject(new Error('lost'));
} catch {
return 'caught'; // runs
}
}Async Callbacks in forEach
array.forEach(async (item) => { await save(item); }) starts every save and returns immediately. forEach ignores the promises, so nobody waits for them and their errors go unhandled. Instead:
for…ofwithawaitto run them one after anotherPromise.all(array.map(…))to run them in parallel
Floating Promises
A promise that is never awaited or given a catch doesn’t throw where it failed. The runtime reports it later instead:
- Browsers fire an
unhandledrejectionevent and log it to the console. - Node.js terminates the process by default, since version 15.
Common sources are a missing await, an async function passed as a callback (forEach, event emitters) and fire-and-forget calls. Two defenses:
- a lint rule such as typescript-eslint’s
no-floating-promises, to catch them in review - a global handler in production that reports to your error tracker
Going Deeper: Combinators, Limits and Thenables
Four Ways to Wait for Many Promises
All four combinators take the same input and differ only in when they settle and with what. Given three operations, A succeeding after 30 ms, B failing after 10 ms and C succeeding after 20 ms:
None of them cancels the losers: A and C keep running after Promise.all has rejected (see the cancellation tip above).
Limiting Concurrency
Promise.all(items.map(fetchItem)) starts every request at once. With 5,000 items that can exhaust connections, hit rate limits or overload the server. A small worker pool keeps a fixed number in flight:
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++; // claimed synchronously, so no two workers take the same item
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
const users = await mapLimit(ids, 5, fetchUser); // at most 5 requests at a time- Results stay in input order.
- The first failure rejects the whole call, like
Promise.all. - Libraries such as
p-limitoffer the same with more options.
Thenables and Promise.withResolvers
awaitandPromise.resolveaccept any thenable, an object with athenmethod, not just real promises.await { then(resolve) { resolve(42) } }gives42. That is how promise libraries interoperate, and also why an object that accidentally has athenproperty behaves strangely when awaited.Promise.withResolvers()(ES2024) returns{ promise, resolve, reject }, which is handy when the code that settles a promise is not the code that creates it, for example wrapping an event-based API.
Test Yourself
Predict the output first, then open the answer. Every answer was checked by running the code.
Cabaran 1
Sync, Microtask or Task?
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
new Promise((resolve) => {
console.log('D');
resolve();
}).then(() => console.log('E'));
console.log('F');Lihat jawapanSembunyikan jawapan
A D F C E B
- Synchronous first:
A,D,F. The executor passed tonew Promiseruns synchronously, soDprints with the other synchronous logs. - Then microtasks, in the order they were queued:
C,E. - Then the timer task:
B.
Cabaran 2
Where Does an Async Function Pause?
async function load() {
console.log('load 1');
await null;
console.log('load 2');
}
console.log('start');
load();
console.log('end');Lihat jawapanSembunyikan jawapan
start, load 1, end, load 2
- An
asyncfunction runs synchronously until its firstawait, even when the awaited value isn’t a promise. - The rest of the function becomes a microtask, which runs after the current synchronous code (
end).
Cabaran 3
What Does the Chain Recover to?
Promise.reject(new Error('boom'))
.then(() => console.log('then 1'))
.catch((error) => console.log('catch', error.message))
.then(() => console.log('then 2'))
.finally(() => console.log('finally'));Lihat jawapanSembunyikan jawapan
catch boom, then 2, finally
then 1is skipped, because the promise is rejected.catchhandles the error and returnsundefined, so the chain is fulfilled again andthen 2runs.finallyruns either way.
Cabaran 4
When Does “Done” Print?
async function saveAll(items) {
items.forEach(async (item) => {
await save(item);
console.log('saved', item);
});
console.log('done');
}
await saveAll([1, 2]);Lihat jawapanSembunyikan jawapan
done first, then saved 1 and saved 2.
forEachignores the promises its callback returns, sosaveAllfinishes before any save completes.- Fix (sequential):
for (const item of items) await save(item) - Fix (parallel):
await Promise.all(items.map(save))
Cabaran 5
Two Quiet Rules
Promise.resolve(1)
.then(2)
.then((value) => console.log('a', value));
Promise.resolve(1)
.finally(() => 2)
.then((value) => console.log('b', value));Lihat jawapanSembunyikan jawapan
a 1 and b 1
thenignores arguments that aren’t functions, so the value passes through unchanged.finallyignores its callback’s return value (unless it throws or returns a rejected promise), so the original value passes through too.
Cabaran 6
Does Promise.all Stop the Others?
const slow = new Promise((resolve) =>
setTimeout(() => {
console.log('slow finished');
resolve();
}, 100),
);
try {
await Promise.all([slow, Promise.reject(new Error('fast fail'))]);
} catch (error) {
console.log('caught', error.message);
}Lihat jawapanSembunyikan jawapan
caught fast fail immediately, then slow finished about 100 ms later.
Promise.allrejects as soon as one input rejects.- It can’t stop the others: promises don’t cancel work.
- Use an
AbortSignalif the remaining work should stop.
Key Takeaways
- A promise settles once, to a value or an error, and its callbacks always run as microtasks.
- Every
thenreturns a new promise: return to pass a value on, throw to reject, and missing handlers pass results through. - An
asyncfunction runs synchronously until its firstawait, then resumes as a microtask, and always returns a promise. - Use
Promise.allfor independent work; awaiting in a loop makes it sequential. - Use
return awaitinsidetry, never passasynccallbacks toforEach, and handle every promise you create. - Promises replaced nested callbacks with values you can return and combine;
async/awaitis the same machinery written as plain code.
Sources
- MDN: Promise concurrency
- MDN: unhandledrejection event
- Node.js docs: --unhandled-rejections
- MDN: Promise.withResolvers()
- ECMAScript specification: Promise objects and Await
- MDN: Using promises and async function
- Promises/A+: The Promises/A+ specification
- Node.js docs: util.promisify
- V8 blog: Faster async functions and promises
- typescript-eslint: no-floating-promises
- MDN: AbortController
Tag
Soalan lazim
What is the difference between return promise and return await promise?
Outside try/catch they behave almost the same. Inside a try block, only return await waits for the promise there, so its rejection is caught by that catch. Without await, the function returns the promise first and the rejection escapes the try.
Should I use Promise.all or a for loop with await?
A loop with await runs one operation after another. Use Promise.all when the operations are independent and can run at the same time, Promise.allSettled when you need every result even if some fail, and a small concurrency limit when there are many operations.
Are promises cancellable?
NoA promise only represents a result. To cancel the work behind it, pass an AbortSignal to APIs that support it, such as fetch, and check it in your own code.
What is the difference between Promise.all and Promise.allSettled?
Promise.all fulfills with all values or rejects as soon as one input rejects. Promise.allSettled always waits for every input and gives an array of { status, value } or { status, reason } objects, so you can handle partial failure.
How do I run many async operations without overloading the server?
Limit concurrency: keep a fixed number of operations in flight with a small worker pool or a library such as p-limit, instead of starting all of them with Promise.all.
What happens to an unhandled promise rejection?
Browsers fire an unhandledrejection event and log an error. Node.js terminates the process by default (since version 15). Handle every promise, and add a global handler that reports to your error tracker.
How do I add a timeout to a promise?
Prefer passing AbortSignal.timeout(ms) to APIs that accept a signal, such as fetch, so the work actually stops. For others, race the promise against a timer, remembering that the original work keeps running.
Does await make code slower?
NoNot noticeably. Each await costs a microtask turn, which is tiny. What matters is accidental sequencing: awaiting independent operations one after another instead of starting them together.