Learn2Code
learn2code
← Back to Blog
JavaScript11 min read

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:

code.js
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.

code.js
1console.log("Step 1");
2const data = fetchDataSync(); // Takes 3 seconds -- everything freezes
3console.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.

code.js
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 1
11// Step 3
12// 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

code.js
1function getUser(id, callback) {
2 // Simulating an API call
3 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:

code.js
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 hell
7 });
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

code.js
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()

code.js
1myPromise
2 .then(result => console.log(result)) // "Operation succeeded!"
3 .catch(error => console.log(error));

Chaining Promises

Promises solve callback hell by allowing chains:

code.js
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:

code.js
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

code.js
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();
  • async before a function means it returns a Promise
  • await pauses execution until the Promise resolves
  • The code reads top to bottom, like synchronous code

Error Handling with try/catch

code.js
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

code.js
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():

code.js
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

code.js
1// Wrong -- logs a Promise object, not the data
2async function getUser() {
3 const data = fetch('/api/user'); // missing await!
4 console.log(data); // Promise { <pending> }
5}
6 
7// Correct
8async function getUser() {
9 const response = await fetch('/api/user');
10 const data = await response.json();
11 console.log(data); // actual user data
12}

Mistake 2: Using await Outside async

code.js
1// Error: await is only valid in async functions
2const data = await fetch('/api/user');
3 
4// Fix: wrap in an async function
5async function main() {
6 const data = await fetch('/api/user');
7}
8main();

Mistake 3: Not Handling Errors

code.js
1// Dangerous -- if fetch fails, the error is swallowed
2async function getUser() {
3 const response = await fetch('/api/user');
4 return response.json();
5}
6 
7// Safe -- errors are caught and handled
8async 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

code.js
1// Slow -- each await waits for the previous one
2const userProfile = await fetchProfile(userId);
3const userPosts = await fetchPosts(userId);
4const userFriends = await fetchFriends(userId);
5 
6// Fast -- all three run simultaneously
7const [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:

  1. Fetch data from a public API and display it
  2. Chain multiple API calls where each depends on the previous result
  3. Use Promise.all() to fetch data from three APIs simultaneously
  4. 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

#javascript#async#promises#async-await#web-development

Ready to practice what you learned?

Apply these concepts with our interactive coding exercises.

Start Practicing