Why asynchronous JavaScript exists
Computers do many things slowly: reading files, querying databases, waiting for a server to respond. If JavaScript stopped and waited for each one, the page would freeze and users would leave. Asynchronous programming is how the language keeps working while slow tasks happen elsewhere.
The mental model is a restaurant. A single waiter takes your order, passes it to the kitchen, and immediately serves another table. When your food is ready, the kitchen rings a bell and the waiter brings it over. The waiter never stands in the kitchen watching the food cook. JavaScript is that waiter, and the event loop is the bell.
The call stack and the event loop
JavaScript has one call stack. Every function call pushes a frame; when it returns, the frame pops. Because there is only one stack, only one thing runs at a time.
Asynchronous APIs are provided by the host environment, not the language. When you call setTimeout, fetch or a file read, the browser or Node.js handles the waiting. When the work finishes, its callback is placed on a queue. The event loop constantly checks: is the call stack empty? If so, take the next callback from the queue and run it.
// order.js
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// start, end, promise, timeout
Two details matter. First, synchronous code always finishes before any callback runs. Second, there are two queues: microtasks (promise callbacks) run immediately after the current task, before tasks (timers, I/O). That is why promise logs before timeout.
Callbacks
The original way to handle asynchronous results is a callback — a function you pass in to be called later.
// callback.js
function getUser(id, callback) {
setTimeout(() => {
callback({ id, name: "Ada" });
}, 300);
}
getUser(1, (user) => {
console.log(user.name);
});
Callbacks work, but they compose poorly. When each step depends on the last, you nest, and the result is callback hell: code that marches diagonally across the screen and makes error handling painful.
// hell.js
getUser(1, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
// three levels deep and counting
});
});
});
Promises
A Promise is an object that represents a value that may not exist yet. It is in one of three states:
- pending — the work is still running.
- fulfilled — it finished successfully with a value.
- rejected — it failed with a reason.
Once settled, a promise never changes state. You attach handlers with then, catch and finally.
// promise.js
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) resolve({ id, name: "Ada" });
else reject(new Error("Invalid id"));
}, 300);
});
}
getUser(1)
.then((user) => console.log(user.name))
.catch((error) => console.error(error.message))
.finally(() => console.log("done"));
Chaining is the key advantage. Each then returns a new promise, so instead of nesting you can flatten the sequence and handle every error in a single catch at the end.
// chain.js
getUser(1)
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error("Something failed:", error));
Combining promises
When several asynchronous operations are involved, the Promise constructor provides combinators.
// combine.js
const [user, posts, settings] = await Promise.all([
getUser(1),
getPosts(1),
getSettings(),
]);
const results = await Promise.allSettled([getUser(1), getUser(-1)]);
const fastest = await Promise.race([getFromCache(), getFromNetwork()]);
const firstSuccess = await Promise.any([mirrorA(), mirrorB()]);
Promise.all runs everything in parallel and rejects if any promise rejects. Promise.allSettled waits for all and reports each outcome. Promise.race settles with the first promise to settle, and Promise.any resolves with the first to succeed. Choosing the right one changes your error behaviour dramatically.
async and await
async and await are syntax built on top of promises. Marking a function async makes it always return a promise. Inside it, await pauses the function until a promise settles, then continues with the resolved value — without blocking the rest of the program.
// await.js
async function loadDashboard() {
try {
const user = await getUser(1);
const posts = await getPosts(user.id);
return { user, posts };
} catch (error) {
console.error("Failed to load dashboard", error);
throw error;
}
}
This reads like synchronous code, which is the whole point. Errors are handled with ordinary try/catch, and finally still runs. The function returns a promise, so callers can await it or chain .then as usual.
Sequential vs parallel
A subtle but expensive mistake is awaiting independent work one item at a time. Each await waits for the previous one, so the total time is the sum. When the tasks do not depend on each other, start them together.
// parallel.js
// Sequential — slow, ~600ms
const a = await slowTask("a");
const b = await slowTask("b");
// Parallel — fast, ~300ms
const [x, y] = await Promise.all([slowTask("x"), slowTask("y")]);
Use sequential awaits only when a later step genuinely needs an earlier result, such as fetching a user before fetching that user’s posts.
Error handling in async code
Rejected promises that nobody handles become unhandled rejections, which can crash Node.js processes and fill the console in browsers.
// errors.js
try {
const res = await fetch("/api/data");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
} catch (error) {
showMessage("Could not load data. Please try again.");
} finally {
hideSpinner();
}
Prefer try/catch around awaited calls, throw Error objects (not strings), and always handle or deliberately re-throw. The Error Handling guide goes deeper on strategies and user-facing messaging.
Common pitfalls
- Forgetting
await. The function returns a promise, not the value, so the next line runs too early. forEachwith async callbacks.forEachignores returned promises; usefor...oforPromise.allwithmap.- Awaiting in a loop when parallel would do. It works but is needlessly slow.
- Not returning inside
then. A missingreturnbreaks the chain and loses the value. - Assuming order. Only
awaitand microtask ordering guarantee sequence; concurrent tasks finish whenever they finish. - Swallowing errors with an empty
catch, hiding the bug you needed to see.
Best practices
- Prefer
async/awaitover long.thenchains for readability. - Run independent work in parallel with
Promise.allorPromise.allSettled. - Always handle rejections, even if only to log and re-throw.
- Keep async functions small and give them one responsibility.
- Use
Promise.allSettledwhen partial results are acceptable. - Cancel work you no longer need with an
AbortController. - Show loading and error states so users understand what is happening.
Where to go next
You now have the full async toolkit. Put it to work with the Fetch API, where promises and await drive every network request, and harden it with the patterns in Error Handling.