
Async JavaScript: Callbacks, Promises and async/await
Almost every interesting thing JavaScript does happens later: a network response arrives, a file finishes reading, a timer fires. This guide explains how the language handles "later", from callbacks through promises to async/await. By the end you will be able to read asynchronous code without guessing, handle errors in it properly, and choose deliberately between running work in parallel and running it in sequence.
It assumes you are comfortable with functions, arrow syntax and objects. If any of that is shaky, the JavaScript and Node.js beginner tutorial covers the ground this post builds on. Everything below runs in a modern browser console or in Node.js without extra packages.
Why async exists at all
JavaScript runs your code on a single thread. There is one call stack, and while a function is on it, nothing else in your program runs. No other function, no click handler, no rendering.
That is a problem, because a network request takes hundreds of milliseconds and a disk read takes a few. If the thread sat and waited, the page would freeze or the server would stop answering other requests for that whole time.
So it does not wait. Slow work is handed off to something outside the JavaScript thread: the browser, or Node's libuv layer, both of which are perfectly capable of doing several things at once. Your function returns immediately, and you leave behind instructions for what should happen when the result comes back. When the stack is empty, the event loop picks up the next finished piece of work and runs the code you left for it.
Two details of that pickup order matter in practice. Promise callbacks go on the microtask queue, which is drained completely before anything else. Timers and I/O callbacks go on the task queue, which is checked afterwards. That explains this:
console.log('1: synchronous');
setTimeout(() => console.log('4: timer'), 0);
Promise.resolve().then(() => console.log('3: microtask'));
console.log('2: synchronous');
The output is 1, 2, 3, 4. Both callbacks are scheduled during the first two lines, but the synchronous code finishes first, then the microtask, then the timer. A setTimeout of 0 does not mean "now", it means "as soon as the current work and all pending microtasks are done".
One consequence is worth keeping in mind: async does not make your code multi-threaded. A tight loop over a million items still blocks everything, promise or not. Async solves waiting, not computing.
Callbacks, and why nesting hurts
The original way to say "run this when the result arrives" was to pass a function in. Node's convention put the error first:
const fs = require('node:fs');
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('could not read config:', err.message);
return;
}
console.log(JSON.parse(data));
});
That reads fine. The trouble starts when one result feeds the next. Each step nests inside the previous one, each needs its own error check, and the code marches to the right:
getUser(userId, (err, user) => {
if (err) return done(err);
getOrders(user.id, (err, orders) => {
if (err) return done(err);
getInvoice(orders[0].id, (err, invoice) => {
if (err) return done(err);
done(null, invoice);
});
});
});
Three steps is already awkward and six is unreadable. The deeper problem is not the indentation, it is that the error handling is duplicated at every level and easy to forget, and that you cannot wrap the whole thing in a single try/catch. A callback runs on a fresh, empty stack long after the surrounding function returned, so a try block around getUser will never see an error thrown inside getOrders.
Promises: an object for a value that is not here yet
A promise is an object representing a result that will exist later. It is pending until it either fulfils with a value or rejects with a reason, and once it settles it never changes again.
You mostly consume promises rather than build them, because modern APIs return them already. fetch does, and so does node:fs/promises. When you do need to wrap an old callback API, the constructor looks like this:
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
The reason promises beat callbacks is chaining. .then() returns a new promise, and if your handler returns a promise, the chain waits for it before continuing. The staircase flattens into a list:
fetch(`/api/users/${userId}`)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((user) => fetch(`/api/orders?user=${user.id}`))
.then((response) => response.json())
.then((orders) => console.log(orders.length))
.catch((error) => console.error('lookup failed:', error.message))
.finally(() => console.log('done'));
One .catch() at the end covers every step above it, because a rejection skips all the .then() handlers until it finds one. .finally() runs either way and is the right place for cleanup like hiding a spinner.
Note the explicit status check. fetch only rejects when the request itself fails, so a 404 or a 500 arrives as a perfectly fulfilled promise with response.ok === false. If you do not check, you will end up calling .json() on an error page.
async/await: the same thing, written flat
async/await is syntax over promises. An async function always returns a promise, and await pauses that function until the promise it is given settles, then hands back the value.
async function loadOrders(userId) {
const userResponse = await fetch(`/api/users/${userId}`);
if (!userResponse.ok) throw new Error(`HTTP ${userResponse.status}`);
const user = await userResponse.json();
const orderResponse = await fetch(`/api/orders?user=${user.id}`);
const orders = await orderResponse.json();
return orders;
}
Same behaviour as the chain, but it reads top to bottom. "Pauses" means the function suspends and returns control to the event loop. The thread is not blocked, and everything else continues to run.
Two rules keep this straightforward. Every value you await should be a promise; awaiting a plain value works but does nothing except delay a tick. And calling an async function without await gives you the promise, not the result, which is the source of an enormous number of stray Promise { <pending> } logs.
Top-level await is available in ES modules, so in a .mjs file or a project with "type": "module" you can await at the top of the file without wrapping it in a function.
Handling errors
Inside an async function, a rejected promise that you await throws, so ordinary try/catch works:
async function main() {
try {
const orders = await loadOrders(42);
console.log(`${orders.length} orders`);
} catch (error) {
console.error('could not load orders:', error.message);
} finally {
console.log('finished');
}
}
The catch that people miss is at the boundary. If you call an async function from synchronous code and do not await it or attach a .catch(), a rejection has nowhere to go. In Node it becomes an unhandled rejection and terminates the process; in the browser it is an unhandled rejection warning in the console. Always terminate the chain somewhere:
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Two smaller points. Catch narrowly. Wrapping twelve awaits in one try block means your handler cannot tell which step failed, so wrap the operation you can actually recover from and let the rest bubble up. And when you re-throw, keep the original: throw new Error('loading orders failed', { cause: error }) preserves the underlying error instead of discarding the stack trace.
Parallel or sequential: choose on purpose
Awaiting one line after another means each request starts only when the previous one has finished. Sometimes that is required, because step two needs step one's output. Often it is not, and you are paying for it anyway:
// Sequential: roughly the sum of all three durations.
const user = await getUser(id);
const settings = await getSettings(id);
const invoices = await getInvoices(id);
If those three do not depend on each other, start them together and await the group. Promise.all takes an array of promises and gives you an array of results in the same order:
// Parallel: roughly as long as the slowest one.
const [user, settings, invoices] = await Promise.all([
getUser(id),
getSettings(id),
getInvoices(id),
]);
The functions are called immediately, so all three requests are in flight before the await runs. Promise.all rejects as soon as any one of them rejects, and the others keep running in the background without anyone reading their results.
When you want every outcome instead, use Promise.allSettled. It never rejects, and returns one object per input with a status of 'fulfilled' or 'rejected':
const results = await Promise.allSettled(urls.map((url) => fetch(url)));
const failed = results.filter((r) => r.status === 'rejected');
console.log(`${failed.length} of ${results.length} failed`);
Promise.race settles with the first promise to finish either way, which is how you build a timeout. Promise.any settles with the first one to succeed and only rejects if all of them fail.
The loop trap
This is the mistake that turns a two second job into a two minute one:
const results = [];
for (const id of userIds) {
results.push(await fetchUser(id)); // one at a time
}
Every iteration waits for the previous request to come back. With two hundred ids and 150 ms each, that is half a minute of mostly idle waiting. If the calls are independent, map them into promises and await the array:
const results = await Promise.all(userIds.map((id) => fetchUser(id)));
Sequential is still the right answer when each step depends on the one before it, when you are writing to a resource where order matters, or when the remote service will rate-limit you. That last one is real: Promise.all over five thousand ids fires five thousand requests at once and you will get throttled, or exhaust the connection pool. In that case process the work in batches, or use a small concurrency limiter.
The related trap is forEach. It ignores the value its callback returns, so an async callback produces promises that nobody awaits:
// Broken: 'done' prints before any user is saved.
userIds.forEach(async (id) => {
await saveUser(id);
});
console.log('done');
Use for...of with await when you want them one at a time, or Promise.all with map when you want them together. forEach is not an option for either.
One more: creating the promise and awaiting it are separate moments. const a = slow(); const b = slow(); await a; await b; runs both in parallel, because both calls happened before the first await. Awaiting on the same line as the call is what makes it sequential.
Where to go next
- JavaScript and Node.js for beginners for the syntax and module foundations this post assumes.
- API integration for putting these patterns to work against real endpoints, including retries and rate limits.
- JavaScript and Node.js tutorials for more walkthroughs in this series.
Stuck on a step, or staring at a Promise { <pending> } that should have been a value? Write to the desk and describe what you are seeing.
Comments
No comments yet. Be the first to share your thoughts.


