Async JavaScript Explained: Callbacks, Promises, and Async/Await
Asynchronous code is where JavaScript beginners get stuck. A clear path from callbacks to promises to async/await, with the mental model that makes it click.
Ask a room of JavaScript learners where the language first got hard, and most will give the same answer: asynchronous code. Promises that never resolve, console.log printing Promise { <pending> }, data that's undefined for no visible reason. If that's you, you're not behind. Async is genuinely the first conceptual wall in JavaScript.
It's also completely climbable. This guide builds the mental model from zero, walks through the three historical styles (callbacks → promises → async/await), and covers the mistakes that cause 90% of beginner async bugs.
The core problem: JavaScript can't wait
JavaScript runs your code on a single thread: one statement at a time, top to bottom. Now consider fetching data from a server, which might take 500 milliseconds. If JavaScript simply stopped and waited, the entire page would freeze: no clicks, no scrolling, no typing. Half a second of frozen UI per request would make the web unusable.
So JavaScript refuses to wait. Instead, slow operations (network requests, timers, file reads) are started and set aside; JavaScript continues running the rest of your code, and the slow operation reports back later when it finishes. That "report back later" is asynchronous programming.
You can see it with the simplest possible example:
console.log("1: start");
setTimeout(() => {
console.log("2: timer finished");
}, 0);
console.log("3: end");
// Prints: 1, 3, 2 (even with a 0ms timer!)
Even a zero-millisecond timer runs after all the regular code, because "set aside for later" means later: the current code always finishes first. Internalize this example and you understand the event loop better than most beginners ever do.
Style 1: Callbacks (understand them, rarely write them)
The original pattern: pass a function to be called when the work finishes.
setTimeout(() => {
console.log("This runs when the timer completes");
}, 1000);
Fine for one step. But real work has sequences (fetch a user, then their orders, then the order details), and callbacks nest:
getUser(userId, (user) => {
getOrders(user.id, (orders) => {
getDetails(orders[0].id, (details) => {
// ...and error handling at every level too
});
});
});
This is "callback hell," and it's why promises were invented. You'll still use callbacks constantly (addEventListener, array methods); you'll just rarely chain async work with them.
Style 2: Promises, a receipt for a future value
A Promise is an object that represents "a value that isn't here yet." Think of it as a receipt from a coffee shop: it's not the coffee, but it entitles you to the coffee when it's ready, or to an explanation if the machine breaks.
A promise is always in one of three states: pending (still working), fulfilled (here's your value), or rejected (here's the error). You attach handlers with .then() and .catch():
fetch("https://api.example.com/user/42")
.then((response) => response.json())
.then((user) => {
console.log(user.name);
})
.catch((error) => {
console.error("Something failed:", error);
});
The key improvement over callbacks: .then() returns a new promise, so sequences chain flat instead of nesting, and one .catch() at the end handles a failure anywhere in the chain.
This is also the moment to fix the most common beginner misconception:
const user = fetch("https://api.example.com/user/42");
console.log(user); // Promise { <pending> }, NOT your data!
fetch returns the receipt, not the coffee. The data only exists inside the .then(), or with the syntax that makes this all readable:
Style 3: async/await, what you'll actually write
async/await is syntax sugar over promises that lets asynchronous code read like synchronous code:
async function showUser(id) {
try {
const response = await fetch(`https://api.example.com/user/${id}`);
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
const user = await response.json();
console.log(user.name);
} catch (error) {
console.error("Something failed:", error);
}
}
Reading rules:
awaitmeans "pause this function until the promise settles, and unwrap the value." Only this function pauses; the rest of the page keeps running.awaitonly works inside a function markedasync.- An
asyncfunction always returns a promise itself; callersawaitit in turn. - Errors are handled with ordinary
try/catch, which is a huge readability win over.catch()chains.
This is the style modern codebases use almost exclusively. You still need to understand promises: async/await is promises underneath, and the moment something misbehaves, the promise model is how you reason about it.
The classic mistakes (and their one-line fixes)
1. Forgetting await:
const data = getData(); // ❌ data is a Promise
const data = await getData(); // ✅ data is your value
If a variable is mysteriously Promise { <pending> } or undefined, look for a missing await. This is the #1 async bug, by a mile.
2. Not checking response.ok: fetch only rejects on network failure. A 404 or 500 is a "successful" fetch of an error page. Always check response.ok (as in the example above) before trusting the body.
3. Awaiting things one-by-one that could run together:
// ❌ Sequential: ~2 seconds total
const user = await fetchUser();
const posts = await fetchPosts();
// ✅ Parallel: ~1 second total
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
When requests don't depend on each other, start them together with Promise.all. It's the easiest real performance win in everyday JavaScript.
4. Using forEach with async: array.forEach(async ...) fires all the callbacks and ignores their promises; the loop "finishes" instantly while the work is still flying. Use a for...of loop with await (sequential) or map + Promise.all (parallel) instead.
How to practice this
Async only clicks through use. Three exercises, in order:
- Predict-then-run: write
console.logs aroundsetTimeouts andfetches, predict the print order, then run it. Repeat until you stop being surprised. - Build a fetch app: a weather widget, a GitHub-profile viewer, or anything that calls a real public API, handles the loading state, and handles a failure (try an invalid username on purpose).
- Refactor a chain: take a
.then().then().catch()example and rewrite it withasync/awaitandtry/catch. Feeling the equivalence is what cements both.
If you want this structured (with exercises checked as you go and an AI tutor for the moment your promise never resolves), asynchronous JavaScript, the event loop, and error-handling patterns are covered in depth in our JavaScript Advanced course, building on the fundamentals. The concepts you've just read are the exact wall between "I can follow tutorials" and "I can build real apps", and worth every hour you put in.
Kodion Team
Kodion Editorial
Written by the team that builds Kodion's courses: working engineers who test every example in the interactive editor before it ships. How we create and review content
Continue Learning
📚 Related Articles
The 7 Classic Bugs in AI-Written Code (With Real Examples)
AI-generated code fails in repeatable patterns. Learn the seven classic bug shapes, from off-by-zero boundaries to swallowed errors, with runnable examples.
The JavaScript Array Methods You Actually Use: map, filter, reduce and Friends
Master map, filter, find, some, every, and reduce with real examples, plus the mutation traps and modern ES2023 methods that keep your code clean.