Understanding Async JavaScript: Callbacks, Promises, and Async/Await
Demystify asynchronous JavaScript. Learn how callbacks, promises, and async/await work with clear examples. Understand why JavaScript handles async operations the way it does.
Learn2Code Team
January 31, 2026
Why JavaScript Needs Async
JavaScript runs on a single thread. This means it can only do one thing at a time. But web applications need to do many things that take time: fetching data from servers, reading files, waiting for user input, and running timers.
If JavaScript waited for each slow operation to complete before moving on, your entire page would freeze every time it fetched data. The browser would become unresponsive. Users would leave.
Asynchronous programming solves this. Instead of waiting, JavaScript starts the operation, moves on to other work, and comes back to handle the result when it is ready.
Understanding async is one of the most important milestones in learning JavaScript. It is also one of the most confusing. This guide walks through the three approaches -- callbacks, promises, and async/await -- in the order they evolved.
The Synchronous Problem
First, understand what synchronous (blocking) code looks like:
1console.log("Step 1");2console.log("Step 2");3console.log("Step 3");4// Output: Step 1, Step 2, Step 3 (in order)Each line waits for the previous one to finish. Simple and predictable. Now imagine Step 2 takes 3 seconds (fetching data from a server). Synchronous code would freeze for 3 seconds before showing Step 3.
1console.log("Step 1");2const data = fetchDataSync(); // Takes 3 seconds -- everything freezes3console.log("Step 3");This is unacceptable for web applications. Async programming lets Step 3 run immediately while Step 2 processes in the background.
Approach 1: Callbacks
A callback is a function passed as an argument to another function, to be called when an operation completes.
1console.log("Step 1");2 3setTimeout(() => {4 console.log("Step 2 (after 2 seconds)");5}, 2000);6 7console.log("Step 3");8 9// Output:10// Step 111// Step 312// Step 2 (after 2 seconds)Notice the order: Step 3 runs before Step 2 because setTimeout is asynchronous. JavaScript does not wait for the timer. It schedules the callback and moves on.
Callbacks for Data Fetching
1function getUser(id, callback) {2 // Simulating an API call3 setTimeout(() => {4 const user = { id: id, name: "Alice" };5 callback(user);6 }, 1000);7}8 9getUser(1, (user) => {10 console.log(user.name); // "Alice" (after 1 second)11});The Callback Hell Problem
Callbacks work for simple cases. But when you need to chain multiple async operations, the code nests deeper and deeper:
1getUser(1, (user) => {2 getOrders(user.id, (orders) => {3 getOrderDetails(orders[0].id, (details) => {4 getShippingStatus(details.trackingId, (status) => {5 console.log(status);6 // We are four levels deep -- this is callback hell7 });8 });9 });10});This pyramid of doom is hard to read, hard to debug, and hard to maintain. Promises were created to solve this.
Approach 2: Promises
A Promise is an object that represents a value that may not be available yet but will be resolved at some point in the future (or rejected if something goes wrong).
A Promise has three states:
- Pending -- the operation has not completed yet
- Fulfilled -- the operation completed successfully
- Rejected -- the operation failed
Creating a Promise
1const myPromise = new Promise((resolve, reject) => {2 const success = true;3 4 if (success) {5 resolve("Operation succeeded!");6 } else {7 reject("Operation failed.");8 }9});Using a Promise with .then() and .catch()
1myPromise2 .then(result => console.log(result)) // "Operation succeeded!"3 .catch(error => console.log(error));Chaining Promises
Promises solve callback hell by allowing chains:
1getUser(1)2 .then(user => getOrders(user.id))3 .then(orders => getOrderDetails(orders[0].id))4 .then(details => getShippingStatus(details.trackingId))5 .then(status => console.log(status))6 .catch(error => console.log("Error:", error));Same logic as the callback hell example, but flat and readable. Each .then() receives the result of the previous step. A single .catch() handles errors from any step in the chain.
The fetch() API
The most common real-world use of promises is the fetch() API:
1fetch('https://api.example.com/users')2 .then(response => response.json())3 .then(data => console.log(data))4 .catch(error => console.error('Failed:', error));fetch() returns a Promise. The first .then() converts the response to JSON (which is also async and returns a Promise). The second .then() receives the parsed data.
Approach 3: Async/Await
Async/await is syntactic sugar built on top of Promises. It makes asynchronous code look and behave like synchronous code, which is much easier to read and write.
The Basics
1async function fetchUser() {2 const response = await fetch('https://api.example.com/users/1');3 const user = await response.json();4 console.log(user);5}6 7fetchUser();asyncbefore a function means it returns a Promiseawaitpauses execution until the Promise resolves- The code reads top to bottom, like synchronous code
Error Handling with try/catch
1async function fetchUser() {2 try {3 const response = await fetch('https://api.example.com/users/1');4 5 if (!response.ok) {6 throw new Error(`HTTP error: ${response.status}`);7 }8 9 const user = await response.json();10 return user;11 } catch (error) {12 console.error('Failed to fetch user:', error.message);13 }14}This is the same pattern as synchronous try/catch. No special async error handling syntax to learn.
The Callback Hell Example, Rewritten
1async function getShippingInfo(userId) {2 const user = await getUser(userId);3 const orders = await getOrders(user.id);4 const details = await getOrderDetails(orders[0].id);5 const status = await getShippingStatus(details.trackingId);6 return status;7}Compare this to the nested callback version. Same logic, dramatically clearer.
Running Async Operations in Parallel
By default, await runs operations sequentially (one after another). If operations are independent, you can run them in parallel with Promise.all():
1// Sequential -- slow (total time = sum of all operations)2const users = await fetchUsers();3const products = await fetchProducts();4const orders = await fetchOrders();5 6// Parallel -- fast (total time = longest operation)7const [users, products, orders] = await Promise.all([8 fetchUsers(),9 fetchProducts(),10 fetchOrders(),11]);Use Promise.all() when the operations do not depend on each other. Use sequential await when each operation needs the result of the previous one.
Common Mistakes
Mistake 1: Forgetting await
1// Wrong -- logs a Promise object, not the data2async function getUser() {3 const data = fetch('/api/user'); // missing await!4 console.log(data); // Promise { <pending> }5}6 7// Correct8async function getUser() {9 const response = await fetch('/api/user');10 const data = await response.json();11 console.log(data); // actual user data12}Mistake 2: Using await Outside async
1// Error: await is only valid in async functions2const data = await fetch('/api/user');3 4// Fix: wrap in an async function5async function main() {6 const data = await fetch('/api/user');7}8main();Mistake 3: Not Handling Errors
1// Dangerous -- if fetch fails, the error is swallowed2async function getUser() {3 const response = await fetch('/api/user');4 return response.json();5}6 7// Safe -- errors are caught and handled8async function getUser() {9 try {10 const response = await fetch('/api/user');11 if (!response.ok) throw new Error(`Status: ${response.status}`);12 return await response.json();13 } catch (error) {14 console.error('Error:', error);15 return null;16 }17}Mistake 4: Unnecessary Sequential Execution
1// Slow -- each await waits for the previous one2const userProfile = await fetchProfile(userId);3const userPosts = await fetchPosts(userId);4const userFriends = await fetchFriends(userId);5 6// Fast -- all three run simultaneously7const [userProfile, userPosts, userFriends] = await Promise.all([8 fetchProfile(userId),9 fetchPosts(userId),10 fetchFriends(userId),11]);When to Use Each Approach
- Callbacks: Rarely use directly. Most modern APIs use Promises. Callbacks appear in older libraries and event handlers like
addEventListener. - Promises with .then(): Good for simple chains. Useful when you need to pass promises around without immediately awaiting them.
- Async/await: Use for most async code. It is the clearest and most maintainable approach. Default to this unless you have a specific reason not to.
Practice Async JavaScript
Async is one of those topics that requires hands-on practice to truly understand. Reading about it creates familiarity, but writing async code yourself builds real comprehension.
Try these exercises:
- Fetch data from a public API and display it
- Chain multiple API calls where each depends on the previous result
- Use
Promise.all()to fetch data from three APIs simultaneously - Add error handling to all of the above
Practice your JavaScript async skills with our interactive exercises and keep our JavaScript cheatsheet bookmarked for quick reference.
Related Reading
- How to Learn JavaScript from Scratch -- master the basics before tackling async
- What Is an API? -- understand APIs before making async API calls
- JavaScript Array Methods: map, filter, reduce -- commonly combined with async data fetching
- React for Beginners: What to Learn First -- React's useEffect hook uses async patterns extensively
