JavaScript is a single-threaded language. That means it can only do one thing at a time. But websites need to do many things at once — load images, fetch data from a server, wait for a user to click something, and still keep the page responsive. How does that work?
The answer is asynchronous programming. Instead of stopping everything and waiting, JavaScript can start a task, move on to other work, and come back when the task is done.
Over the years, JavaScript gave us three ways to handle this: Callbacks, then Promises, then async/await. Each one was invented to fix the problems of the previous one. This guide walks through all three from scratch in simple English.
Why Async Programming Exists
Imagine you go to a coffee shop. You order a coffee. The barista does not just freeze and stare at you for three minutes while the coffee brews. They take your order, start brewing, then go serve someone else. When your coffee is ready, they call your name.
That is exactly how async JavaScript works. You tell JavaScript to go do something that takes time (like fetching data from the internet). JavaScript starts it and moves on. When the result comes back, JavaScript runs your follow-up code.
Without async programming, your entire web page would freeze every time it waited for anything. No scrolling. No clicking. Just a frozen screen.
// SYNCHRONOUS — the page freezes until this finishes
const data = fetchDataSync('https://api.example.com/users');
console.log(data); // nothing runs until we have the data
// ASYNCHRONOUS — JavaScript keeps going, page stays responsive
fetchDataAsync('https://api.example.com/users', (data) => {
console.log(data); // runs when ready
});
console.log('This line runs immediately while data loads!');
ℹ️
The Event Loop is what makes all of this possible. It is a system inside JavaScript that watches for finished tasks and runs their follow-up code at the right time. All three async patterns — callbacks, Promises and async/await — rely on the event loop underneath.
1. Callbacks — The Original Way
A callback is simply a function you pass to another function as an argument, so that the second function can call it later when it is done.
Think of it like leaving your phone number at a restaurant. You do not stand at the counter waiting. You say "call me when my table is ready" and go sit down. The callback is your phone number.
How Callbacks Work
// A function that takes a callback as its last argument
function fetchUser(userId, callback) {
setTimeout(() => {
const user = { id: userId, name: 'Shashank' };
callback(null, user); // Node.js convention: first arg is error, second is data
}, 1000);
}
// Call it and pass a function to run when the data arrives
fetchUser(42, (err, user) => {
if (err) {
console.error('Something went wrong:', err);
return;
}
console.log('Got user:', user.name);
});
The Callback Hell Problem
Callbacks work fine for one task. But what if you need to do several async tasks in a row, where each one depends on the previous result? You end up with functions nested inside functions inside functions. This is called callback hell or the pyramid of doom.
getUser(userId, (err, user) => {
if (err) return handleError(err);
getPosts(user.id, (err, posts) => {
if (err) return handleError(err);
getComments(posts[0].id, (err, comments) => {
if (err) return handleError(err);
getLikes(comments[0].id, (err, likes) => {
if (err) return handleError(err);
// finally... deeply nested, hard to read
render(user, posts, comments, likes);
});
});
});
});
⚠️
Problems with callback hell: the code is hard to read, you have to write error handling at every single level, and there is no easy way to run multiple tasks at the same time. Promises were invented to solve all three of these problems.
2. Promises — A Better Way
A Promise is an object that represents a task that will finish in the future. It is like a receipt you get when you order something online. Right now you do not have the package. But you have a promise that it will arrive. When it does, you open it. If it does not arrive, you file a complaint.
A Promise has three states:
- Pending — the task is still running
- Fulfilled — the task finished successfully, you have the result
- Rejected — something went wrong, you have an error
Creating and Using a Promise
// Create a Promise — wrap your async work inside it
function fetchUser(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId > 0) {
resolve({ id: userId, name: 'Shashank' }); // success
} else {
reject(new Error('User ID must be greater than zero')); // failure
}
}, 1000);
});
}
// Use the Promise — .then() runs on success, .catch() runs on failure
fetchUser(42)
.then(user => console.log('Got user:', user.name))
.catch(err => console.error('Error:', err.message))
.finally(() => console.log('This always runs, success or failure'));
Promise Chaining — No More Nesting
The biggest win with Promises is chaining. Instead of nesting callbacks inside each other, you chain .then() calls in a straight line. Each .then() gets the result of the previous one and returns a new Promise.
// Compare this to the callback hell version above
getUser(userId)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => getLikes(comments[0].id))
.then(likes => render(likes))
.catch(err => handleError(err));
// One single .catch() handles errors from ANY step above
Running Multiple Promises at Once
Sometimes you want to run several async tasks at the same time and wait for all of them to finish. Promise gives you built-in tools for this:
// Promise.all — run all at once, wait for ALL to finish
// If ANY fails, the whole thing fails
const [user, posts, settings] = await Promise.all([
fetchUser(42),
fetchPosts(42),
fetchSettings(42)
]);
// Promise.race — resolves with whichever finishes FIRST
const fastest = await Promise.race([
fetchFromPrimaryServer(),
fetchFromBackupServer()
]);
// Promise.allSettled — wait for ALL, even if some fail
// You get an array of results, each with status and value/reason
const results = await Promise.allSettled([
taskThatMayFail(),
taskThatWillSucceed(),
anotherTask()
]);
results.forEach(r => console.log(r.status, r.value ?? r.reason));
3. async and await — The Modern Way
async/await was added to JavaScript in 2017. It is built on top of Promises but makes async code look and feel like normal, synchronous code. Most developers today use this as their default way of writing async JavaScript.
The idea is simple: mark a function with the async keyword and then use await inside it to pause and wait for a Promise to finish before moving to the next line.
Basic Usage
// Mark the function as async
async function loadDashboard(userId) {
const user = await getUser(userId); // wait for user
const posts = await getPosts(user.id); // then wait for posts
const comments = await getComments(posts[0].id); // then wait for comments
return { user, posts, comments };
}
// async functions always return a Promise
loadDashboard(42).then(renderPage).catch(showError);
Error Handling with try and catch
The best thing about async/await is that errors work the same way as in normal synchronous code. You use the familiar try and catch blocks you already know.
async function loadUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
// Check if the HTTP response was OK (status 200-299)
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const user = await response.json();
return user;
} catch (err) {
// Handles network errors AND the thrown error above
console.error('Failed to load user:', err.message);
throw err; // re-throw so the caller can also handle it if needed
} finally {
// This always runs, whether success or failure
hideLoadingSpinner();
}
}
Running Tasks in Parallel with async/await
A very common mistake beginners make is using await on every task one by one, even when the tasks do not depend on each other. This is much slower than running them at the same time.
// SLOW: waits 3 seconds total (1s + 1s + 1s)
async function slowWay() {
const a = await fetchA(); // waits 1 second
const b = await fetchB(); // then waits another 1 second
const c = await fetchC(); // then waits another 1 second
return [a, b, c];
}
// FAST: all three run at the same time, done in ~1 second
async function fastWay() {
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
return [a, b, c];
}
✅
Simple rule: if two await calls do not use each other's result, run them together with Promise.all(). This one change can make your app dramatically faster.
Pattern Comparison — Which One Should You Use?
| Pattern |
Readability |
Error Handling |
Parallel Tasks |
Use When |
| Callbacks |
Poor |
Manual at every level |
Very complex |
Old code or simple single events |
| Promises |
Good |
One .catch() for all |
Promise.all() |
Library code and promise chains |
| async/await |
Excellent |
try and catch blocks |
Promise.all() with await |
Modern code — your default choice |
ℹ️
Which should you learn first? Start with Promises because async/await is built on top of them. Once you understand Promises, async/await will feel natural and you will understand what it is actually doing underneath.
Real World Example — Loading a Dashboard
Here is a complete, production-style example that combines everything. This is the kind of code you would actually write when building a real application:
// A helper that adds a timeout to any fetch request
const fetchWithTimeout = (url, ms = 5000) => {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request took too long')), ms)
);
return Promise.race([fetch(url), timeout]);
};
async function loadDashboard(userId) {
showSpinner();
try {
// Fire all three independent requests at the same time
const [userRes, statsRes, notifRes] = await Promise.all([
fetchWithTimeout(`/api/users/${userId}`),
fetchWithTimeout(`/api/stats/${userId}`),
fetchWithTimeout(`/api/notifications/${userId}`)
]);
// Parse all three JSON responses at the same time too
const [user, stats, notifications] = await Promise.all([
userRes.json(),
statsRes.json(),
notifRes.json()
]);
// Now fetch posts — this needs user.id, so it comes after
const posts = await fetch(`/api/posts?author=${user.id}`)
.then(r => r.json());
renderDashboard({ user, stats, notifications, posts });
} catch (err) {
showErrorMessage(err.message);
} finally {
hideSpinner(); // always hide spinner no matter what
}
}
⚡ Key Takeaways
- JavaScript is single-threaded but async programming lets it start tasks and come back to them later without freezing the page.
- Callbacks are the simplest form. You pass a function to run when the task is done. They work but get messy when you chain many steps together.
- Promises solve callback hell with clean chaining using .then() and a single .catch() that handles errors from the whole chain.
- async/await makes Promises look like normal code. Mark a function with async and use await to pause until a Promise resolves.
- Use try and catch with async/await for clean error handling, and finally for cleanup that always runs.
- If two tasks do not depend on each other, use Promise.all() to run them at the same time. This can cut your load time by 2 or 3 times.
- Use Promise.allSettled() when you need all results even if some tasks fail.
- In production, add timeouts to your fetch calls using Promise.race() so a slow server never hangs your app forever.
Tags:
JavaScript
Async
Promises
Callbacks
ES2017
Performance
Shashank Shekhar
Founder & Creator — Hoopsiper.com
Full stack developer and educator. Building Hoopsiper to help developers learn faster through practical, no-fluff coding guides on JavaScript, AI/ML, Python and modern web development.