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.

JavaScript — sync blocks everything, async does not
// 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

JavaScript — a basic callback
// 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.

JavaScript — callback hell, hard to read and maintain
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

JavaScript — creating and consuming 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.

JavaScript — chaining Promises, flat and readable
// 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:

JavaScript — Promise.all, race and allSettled
// 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

JavaScript — async/await basics
// 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.

JavaScript — error handling with try catch finally
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.

JavaScript — sequential is slow, parallel is fast
// 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:

JavaScript — full production async pattern
// 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.