A complete guide to promises and async/await
Asynchronous code is unavoidable in JavaScript — anything involving the network, the
file system, or timers happens later, not now. Promises are the language's standard
abstraction for "a value you'll have eventually," and async/await is the syntax that
makes them read like ordinary code. This guide covers what promises are, how chaining
and error handling work, the combinator methods, and the patterns and traps that
separate confident developers from confused ones.
What a promise is
A promise is an object representing the eventual result of an async operation. It lives in one of three states:
pending— not yet settled.fulfilled— completed successfully, holds a value.rejected— failed, holds a reason.
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve('done'), 100)
})
p.then(value => console.log(value)) // 'done'
Two guarantees make promises composable. A promise settles once — after that its
state and value are immutable, so calling resolve/reject again does nothing. And
.then/.catch callbacks always run asynchronously as microtasks, never
synchronously — even for an already-resolved promise. One subtlety: the executor
function you pass to new Promise runs synchronously, immediately; only the
callbacks are deferred.
Chaining
Each .then returns a new promise that resolves to whatever its callback returns.
That's what lets you chain steps into a linear sequence instead of nesting callbacks.
fetch('/api/user')
.then(res => res.json()) // returns parsed body
.then(user => fetch(`/posts/${user.id}`))
.then(res => res.json())
.then(posts => console.log(posts))
.catch(err => console.error(err)) // one handler for any step
Returning a value passes it down; returning a promise makes the chain wait for it
(flattening — promises never nest as Promise<Promise<T>>); throwing rejects the
chain. The classic bug is forgetting to return inside a .then, which hands the
next step undefined. Keep chains flat by returning rather than nesting.
async/await
async/await is syntactic sugar over promises. An async function always returns
a promise; await pauses the function until a promise settles, then resumes with
its value.
async function load() {
const res = await fetch('/api') // pause until resolved
return res.json() // the caller's promise adopts this
}
await only suspends the current function — the rest of the program keeps running.
Under the hood, code after an await becomes a .then continuation scheduled as a
microtask, so it always runs after the current synchronous task. Even await 5 (a
non-promise) defers one microtask tick.
Error handling
With raw promises, attach .catch(); a single trailing .catch handles a rejection
from any prior step. With async/await, an awaited rejected promise throws, so use
try/catch.
try {
const data = await load()
use(data)
} catch (err) {
console.error(err)
} finally {
hideSpinner() // runs on success or failure
}
Throwing inside a .then rejects the promise it returns, so errors skip subsequent
.thens until a .catch — exactly like synchronous try/catch. A rejected promise
with no handler becomes an unhandled rejection: browsers fire an
unhandledrejection event; Node warns and (recently) crashes. Always handle rejections
at the source — a global handler is a safety net, not a substitute.
Running tasks: sequential vs parallel
The difference is when you start each task. Awaiting in a loop is sequential — often an accidental performance bug. To run in parallel, start them all, then await together.
// sequential: ~A + B + C
for (const url of urls) results.push(await fetch(url))
// parallel: ~max(A, B, C)
const results = await Promise.all(urls.map(url => fetch(url)))
Use sequential when each step depends on the previous one or you must respect a rate
limit; use parallel for independent work. For thousands of tasks, cap the in-flight
count with a concurrency limit (a worker pool, or a library like p-limit) so you
don't overwhelm the server.
A common gotcha: forEach is not async-aware — it ignores the callback's returned
promise, so await inside it does nothing useful. Use for...of (sequential) or
map + Promise.all (parallel).
The combinators
await Promise.all([a, b, c]) // all values, but rejects as soon as ANY rejects
await Promise.allSettled([a, b, c]) // waits for all; never rejects; {status, value|reason}[]
await Promise.race([a, b]) // first to SETTLE (fulfill or reject) wins
await Promise.any([a, b]) // first to FULFILL; rejects only if all reject
allfor all-or-nothing (one failure should abort).allSettledfor partial failures — dashboards, batch jobs where you want every result.racefor timeouts ("work vs a timer").anyfor "first success wins" (fastest mirror).
Watch the empty-array edge cases: all([])/allSettled([]) resolve immediately, but
any([]) rejects with AggregateError and race([]) stays pending forever.
Converting callbacks and cancellation
Wrap a callback API in new Promise, but only when it isn't already
promise-based — wrapping an existing promise in new Promise is a well-known
anti-pattern.
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
Promises themselves can't be cancelled — once started they run to settlement. The
standard approach is an AbortController: pass its signal to fetch/timers and call
abort() to stop the underlying work.
const ctrl = new AbortController()
fetch('/slow', { signal: ctrl.signal }).catch(e => {
if (e.name === 'AbortError') console.log('cancelled')
})
ctrl.abort()
Why microtasks matter here
console.log(1)
setTimeout(() => console.log(2), 0) // macrotask
Promise.resolve().then(() => console.log(3)) // microtask
console.log(4)
// 1, 4, 3, 2
Promise continuations are microtasks, which drain after the current synchronous code
and before the next macrotask (setTimeout). That's why a resolved promise's
.then always runs before a timer — a favorite interview puzzle.
Recap
A promise is a one-time, immutable representation of a future value. Chaining
sequences async steps (return values to pass them down, return promises to wait);
async/await makes that read synchronously. Handle errors with .catch or
try/catch, and never leave a rejection unhandled. Choose sequential vs
parallel deliberately, pick the right combinator for your failure semantics,
use AbortController to cancel, and remember promise callbacks are microtasks that
beat timers. With those pieces, asynchronous JavaScript stops being a source of
surprises.