Async Models
Date: 2026-08-17
Callbacks, promises, async/await, coroutines and observables are the same problem solved with different ergonomics: how to express “do this when the waiting finishes” without blocking. Knowing they’re one problem makes each new one cheap to learn.
An async model is a way of expressing work that completes later. The underlying mechanism is nearly always the same — register interest, return control, resume on completion. What differs is how the code reads and how errors propagate.
The same operation, four ways
Callback
getUser(id, (err, user) => {
if (err) return handle(err)
getOrders(user, (err, orders) => {
if (err) return handle(err)
render(orders)
})
})Promise
getUser(id)
.then(getOrders)
.then(render)
.catch(handle)Async / await
try {
const user = await getUser(id)
const orders = await getOrders(user)
render(orders)
} catch (e) { handle(e) }Observable / stream
from(getUser(id))
.pipe(switchMap(getOrders))
.subscribe({ next: render, error: handle })Identical work. The difference is entirely in error handling and readability — which is not a small thing, because that’s where the bugs are.
What each fixed
CALLBACKS
the original. Nesting grows rightwards,
errors are a convention you can forget,
and a callback can be called twice
PROMISES
a value representing a future result
→ composable, chainable
→ errors propagate down the chain
→ settles exactly once
ASYNC / AWAIT
promises with synchronous-looking syntax
→ try/catch works normally
→ loops and conditionals work normally
← still promises underneath
OBSERVABLES
for MANY values over time, not one
→ cancellation, backpressure, operators
→ heavier concept, real payoff for
streams and event sequences
Promises are one value; observables are many. That’s the actual dividing line — a fetch is a promise, a websocket is a stream.
The distinction that catches people
A promise represents work already started. Creating it begins the operation; await only waits for it. The promise state machine itself is in Promises and Async Await.
// SEQUENTIAL — 300ms
const a = await fetchA()
const b = await fetchB()
// CONCURRENT — ~100ms
const pa = fetchA() // both start
const pb = fetchB() // immediately
const a = await pa
const b = await pb
// same thing, idiomatic
const [a, b] = await Promise.all([
fetchA(), fetchB()
])await in a loop is the trap, and it’s extremely common — n sequential round trips where one batched call or a Promise.all would do — Concurrency and Parallelism, Latency and Bandwidth.
Combinators worth knowing
Promise.all all succeed, or reject
on the FIRST failure
Promise.allSettled never rejects; returns
status for each
Promise.race first to settle, win
or lose
Promise.any first to SUCCEED
allSettled is usually what you want when fetching independent things — one failed recommendation call shouldn’t take the whole page down.
Where it goes wrong
- Unhandled rejections. A promise with no
.catchand no enclosingtryfails silently in some environments and crashes the process in others. Always terminate a chain - Forgetting
await. Returns a promise where a value was expected — often no error, just[object Promise]in the output or a condition that’s always truthy forEachwith an async callback doesn’t wait. It returns immediately, having started n operations nobody is tracking. Usefor...ofwithawait, orPromise.all(map(...))- Fire-and-forget error loss. An async function called without
awaitor.catchdiscards its failure - No cancellation in promises. A promise cannot be cancelled — only ignored. Use
AbortControlleron the underlying operation, or the response arrives after the component that wanted it has gone — Race Conditions
The one thing to carry
Async is not concurrency and it is not parallelism. It’s a way of not blocking while something else takes time — The Event Loop is the machinery underneath, and every model above is a different syntax over the same queue.