Asynchronous JavaScript confuses almost everyone at first, and the confusion is reasonable: the code appears to run out of order, errors vanish, and await sometimes makes things slower rather than faster. This guide explains callbacks, promises and async/await as one continuous story, so that each concept explains the next.
Why JavaScript needs any of this
JavaScript runs on a single thread. There is one call stack, and only one thing executes at a time. If reading a file or waiting for a network response blocked that thread, the entire page would freeze — no scrolling, no clicks, no rendering.
So anything slow is handed off. The browser or Node runtime performs the work elsewhere and notifies your code when it is finished. That notification mechanism is what callbacks, promises and async/await are three successive designs for.
The event loop, briefly
You do not need to memorise this, but one mental model prevents most confusion.
When an async operation completes, its continuation is placed in a queue rather than run immediately. The event loop moves items from the queue onto the call stack only when the stack is empty — that is, when the currently running synchronous code has finished completely.
console.log("first");
setTimeout(() => console.log("second"), 0);
Promise.resolve().then(() => console.log("third"));
console.log("fourth");
// first, fourth, third, second
Two things surprise people here. A zero-millisecond timeout still runs after all synchronous code, because zero means “as soon as the stack is clear”, not “now”. And the promise callback runs before the timeout despite being registered later, because promise callbacks go on a higher-priority microtask queue that is drained completely before the next macrotask.
Callbacks: the original approach
A callback is simply a function passed to another function, to be called when work finishes.
fs.readFile("data.txt", "utf8", function (err, data) {
if (err) return console.error(err);
console.log(data);
});
Note the convention in Node: the error comes first. Checking it before touching the data is not optional, because on failure data is undefined.
Callbacks work. They become painful when operations depend on each other:
getUser(id, function (err, user) {
if (err) return handle(err);
getOrders(user.id, function (err, orders) {
if (err) return handle(err);
getOrderItems(orders[0].id, function (err, items) {
if (err) return handle(err);
// three levels deep, three separate error checks
});
});
});
This is “callback hell”, and the problem is not only the indentation. Error handling is duplicated at every level, the code reads inside-out rather than top-down, and a try/catch around the whole thing catches nothing, because each callback runs on a different turn of the event loop with a fresh stack.
Promises: a value that arrives later
A promise is an object representing a result that does not exist yet. It has three states: pending, fulfilled with a value, or rejected with a reason. Once settled, it never changes.
getUser(id)
.then(user => getOrders(user.id))
.then(orders => getOrderItems(orders[0].id))
.then(items => console.log(items))
.catch(error => handle(error))
.finally(() => hideSpinner());
Two improvements are doing the heavy lifting. The chain is flat rather than nested. And a single .catch() at the end handles a rejection from any step, because rejections propagate down the chain until something catches them.
The critical rule of chaining: return the promise from inside .then(). Forgetting the return is the most common promise bug — the chain continues immediately with undefined instead of waiting.
// Broken: next .then receives undefined
.then(user => { getOrders(user.id); })
// Correct
.then(user => getOrders(user.id))
async/await: promises with cleaner syntax
async/await is not a replacement for promises. It is syntax for consuming them. An async function always returns a promise, and await pauses inside that function until a promise settles.
async function loadData(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const items = await getOrderItems(orders[0].id);
return items;
} catch (error) {
handle(error);
} finally {
hideSpinner();
}
}
This reads like synchronous code while behaving asynchronously. Regular try/catch works, which it never did with callbacks. Debugger stepping works naturally, and stack traces are more useful.
The pause is local to the function. The rest of your program continues running while an awaited operation is in flight — await does not block the thread.
The mistake that makes async code slow
This is the single most important practical point in the article.
// Sequential: total time is the sum
const user = await getUser(id);
const settings = await getSettings(id);
const notifications = await getNotifications(id);
If each takes 300ms, this takes 900ms. But none of them depends on the others, so the waiting is pure waste.
// Concurrent: total time is the slowest one
const [user, settings, notifications] = await Promise.all([
getUser(id),
getSettings(id),
getNotifications(id)
]);
Now it takes roughly 300ms. Use sequential await only when a later call genuinely needs an earlier result.
The same mistake appears inside loops:
// Slow: one at a time
for (const id of ids) {
results.push(await fetchItem(id));
}
// Fast: all at once
const results = await Promise.all(ids.map(id => fetchItem(id)));
One caveat: firing five hundred requests simultaneously will hit rate limits or exhaust connections. For large lists, batch them in groups rather than releasing all at once.
The promise combinators
Four methods cover different needs, and picking the wrong one causes subtle bugs.
Promise.all— resolves when all succeed; rejects immediately if any fails. Use when you need every result.Promise.allSettled— always resolves, with an array describing each outcome. Use when partial failure is acceptable, such as loading several independent dashboard widgets.Promise.race— settles with the first promise to settle, success or failure. Commonly used to implement timeouts.Promise.any— resolves with the first success, ignoring failures unless all fail.
A timeout pattern using race:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out")), ms)
);
return Promise.race([promise, timeout]);
}
Errors that disappear
An unhandled rejection is the async equivalent of a silent failure, and it happens in two common ways.
Calling an async function without awaiting or catching it:
// Rejection is unhandled
saveData(payload);
// Handled
saveData(payload).catch(err => logger.error(err));
Using async callbacks in array methods that ignore return values:
// forEach does not wait, and errors escape
items.forEach(async (item) => { await save(item); });
console.log("done"); // runs before anything is saved
// Use a for...of loop, or map with Promise.all
for (const item of items) { await save(item); }
forEach has no concept of promises. It calls each function, discards the returned promise, and moves on. This surprises people repeatedly.
Converting callbacks to promises
When working with an older callback-based API, wrap it once rather than mixing styles throughout your code:
function readFileAsync(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf8", (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
Node provides util.promisify for this pattern, and most modern libraries already return promises.
Avoid creating a promise around something that is already a promise — a common beginner pattern known as the explicit construction antipattern. If a function already returns a promise, just return it.
Cancelling work you no longer need
Promises cannot be cancelled once started, which surprises people coming from other languages. What you can do is abort the underlying operation and ignore the result.
For network requests, AbortController is the standard mechanism:
const controller = new AbortController();
fetch("/api/search?q=" + term, { signal: controller.signal })
.then(res => res.json())
.catch(err => {
if (err.name === "AbortError") return; // expected
throw err;
});
// Later, when the user types again:
controller.abort();
This matters most in search-as-you-type interfaces, where responses can arrive out of order. Without cancellation, a slow request for “re” can land after a fast request for “react” and overwrite the correct results with stale ones. Aborting the previous request removes the race entirely.
In component-based UIs, the same idea prevents state updates after unmount. Abort the request or set a cancelled flag in the cleanup function, and check it before calling any setter.
Debugging async code
Three habits save considerable time. Put a breakpoint inside the .then() or after the await rather than on the calling line, since the calling line has already finished by the time the result arrives. Watch the Network tab alongside the console, because a failing request often explains a confusing error message. And add a global handler during development so nothing fails silently:
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled rejection:", event.reason);
});
In Node the equivalent is process.on("unhandledRejection", handler). Either one turns an invisible failure into a visible one, which is usually the whole battle.
Frequently asked questions
Does await block the whole page?
No. It pauses only the async function it appears in. Other code, event handlers and rendering continue normally. Conflating the two is the root of most confusion about this syntax.
Can I use await at the top level of a file?
In ES modules, yes — top-level await is supported. In CommonJS files and classic scripts it is not, and the usual workaround is an immediately-invoked async function.
Should I still learn callbacks?
Yes, enough to read them. Event handlers, array methods and many older libraries use them, and you will meet callback-based code in any established codebase. You simply should not write new chained callback logic.
Is async/await slower than promises?
No meaningfully. It compiles to promise machinery underneath. What is slower is sequential awaiting of independent operations, which is a usage mistake rather than a property of the syntax.
Conclusion
Async JavaScript makes sense once you see the progression: callbacks notify you when work finishes, promises make that notification a value you can chain and catch, and async/await makes consuming those promises read like ordinary code. Learn the event loop well enough to predict ordering, always handle rejections explicitly, return your promises inside chains, and reach for Promise.all whenever operations do not depend on each other. Those few habits cover almost everything you will encounter in real work.