JavaScript runs on a single thread. One thing at a time, no exceptions.
That sounds like a crippling limitation until you notice that almost nothing a browser does is CPU work. It is waiting — for a network response, a file, a timer, a click. So instead of blocking that one thread while it waits, JavaScript hands the slow job to the runtime and says "call me back when it's done."
Everything that follows — callbacks, promises, async/await — is the same idea. What changed three times is how you write it.
Step 1: Callbacks
The original approach. You pass a function in, and it gets called when the work finishes.
function fetchData(callback) {
setTimeout(() => {
callback("Data received");
}, 1000);
}
fetchData((data) => {
console.log(data); // "Data received", one second later
});
Clean enough for one operation. The trouble starts when the next step depends on the previous one:
getUser(userId, (user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0].id, (details) => {
getShipping(details.shipmentId, (shipping) => {
console.log(shipping); // four levels deep, and we haven't handled a single error
});
});
});
});
This is callback hell, and the shape is only half the problem. The real problems:
- Error handling has no structure. Every callback has to check for failure on its own. There is no
try/catch that covers the whole chain, because by the time the callback runs, the try block has long since exited.
- You cannot return a value. The result only exists inside the innermost function, so any code that needs it has to move in there too.
- Control flow is inside-out. Running three things in parallel and waiting for all of them means hand-writing a counter.
Step 2: Promises
A Promise is an object that represents a value you do not have yet. It is in one of three states: pending, fulfilled, or rejected — and once it settles, it never changes again.
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data received");
}, 1000);
});
}
fetchData()
.then((data) => console.log(data))
.catch((err) => console.error(err));
The breakthrough is that the pending result is now a value you can hold in a variable and pass around. That flips the nesting inside-out:
getUser(userId)
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetails(orders[0].id))
.then((details) => getShipping(details.shipmentId))
.then((shipping) => console.log(shipping))
.catch((err) => console.error("anything above failed:", err));
Four levels of nesting became a flat chain, and one .catch() at the end covers every step. Because promises are values, parallel work stops being a hand-rolled counter:
const [user, settings, notifications] = await Promise.all([
getUser(id),
getSettings(id),
getNotifications(id),
]);
Still, .then() chains have their own friction. Sharing a variable between two steps means either nesting again or hoisting it outside the chain, and conditional logic mid-chain gets awkward fast.
Step 3: Async/await
async/await is syntactic sugar over promises. Not a new mechanism — the same promises, written as if the code were synchronous.
async function loadShipping(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
return getShipping(details.shipmentId);
}
Two rules explain the whole feature:
- An
async function always returns a promise, whatever you return inside it.
await pauses that function until the promise settles, then gives you the value — without blocking the thread. Everything else in your app keeps running.
And the error handling you always wanted comes back:
async function loadShipping(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
return await getShipping(orders[0].shipmentId);
} catch (err) {
console.error("something in there failed:", err);
return null;
} finally {
hideSpinner();
}
}
Intermediate variables are just variables. if, for, and try/catch work normally. That is the entire pitch, and it is enough.
The mistake everyone makes once
await inside a loop runs things one after another, not together:
// Slow: 100 users, one request at a time
for (const id of userIds) {
results.push(await getUser(id)); // waits for each before starting the next
}
// Fast: all 100 requests in flight at once
const results = await Promise.all(userIds.map((id) => getUser(id)));
Same output, wildly different wall-clock time. Sequential await is correct when step N genuinely needs step N−1's result. When the calls are independent, it is just slow.
Worth knowing its stricter sibling too: Promise.all rejects the moment any one promise rejects. If you want every result regardless, Promise.allSettled gives you the full list with each outcome tagged.
What actually changed
|
Callbacks |
Promises |
Async/await |
| Chaining |
Nested |
Flat .then() |
Plain sequential lines |
| Errors |
Manual, per callback |
One .catch() |
try/catch |
| Parallel work |
Hand-rolled counter |
Promise.all |
await Promise.all |
| Reads like |
A pyramid |
A pipeline |
Normal code |
Note what is not in that table: performance. None of these are faster than the others. There is still one thread, one event loop, and one queue of callbacks waiting their turn. Async/await compiles down to promises, and promises schedule callbacks.
That is what "syntactic sugar" means — the machinery never changed. What changed is that you can now read the code and follow it, which turns out to matter more than almost anything else.