Asynchronous JavaScript: Promises, Async/Await Explained

by Didin J. on Oct 20, 2025 Asynchronous JavaScript: Promises, Async/Await Explained

Learn asynchronous JavaScript with Promises and async/await explained in simple terms. Includes real-world examples, error handling, and best practices.

In JavaScript, not everything happens instantly. Some operations — like fetching data from an API, reading a file, or setting a timer — take time to complete. If JavaScript waited for each of these tasks before moving to the next, our apps would freeze and feel painfully slow. This is where asynchronous programming comes to the rescue.

JavaScript is single-threaded, meaning it can only do one thing at a time. To handle multiple tasks efficiently, it uses an event loop and asynchronous callbacks to keep the main thread responsive. Instead of blocking execution, JavaScript delegates time-consuming operations and continues running other code — this makes it possible to build fast, interactive web applications.

However, managing asynchronous behavior wasn’t always easy. In the early days, developers relied on callbacks, which often led to deeply nested and hard-to-read code — commonly known as “callback hell.” To solve this problem, Promises were introduced in ES6 (ECMAScript 2015), providing a cleaner way to handle async results. Later, async/await came in ES2017, simplifying Promises even further by allowing developers to write asynchronous code that looks and behaves like synchronous code.

In this tutorial, we’ll dive deep into how asynchronous JavaScript works, starting from Promises to async/await. You’ll learn how to write clean, efficient, and easy-to-understand asynchronous code with plenty of real-world examples and best practices.


Prerequisites

Before diving into asynchronous JavaScript, make sure you’re comfortable with a few JavaScript fundamentals. This tutorial assumes you already understand:

  • Basic JavaScript syntax — variables, functions, and objects.

  • ES6 features such as let, const, and arrow functions (()=>{}).

  • Basic understanding of callbacks — passing one function into another as an argument.

If you’ve built simple JavaScript apps or interacted with APIs before, you’re ready to go.

Tools You’ll Need

You don’t need a complex setup to follow along. Just have the following:

  • A text editor – Visual Studio Code is highly recommended.

  • Node.js – Download and install Node.js to run your JavaScript code locally.

  • Browser console – You can also experiment directly in your browser’s Developer Tools (press F12 or Ctrl + Shift + I → Console tab).

Example Setup

Create a new folder for this tutorial, for example:

mkdir async-js-tutorial
cd async-js-tutorial

Then create a JavaScript file to test examples:

touch index.js

You can run your code anytime using:

node index.js

With this simple setup, you’re ready to explore asynchronous programming step by step.


Understanding Asynchronous JavaScript

Before we jump into Promises and async/await, it’s important to understand why asynchronous programming exists and how JavaScript handles it behind the scenes.

JavaScript Is Single-Threaded

JavaScript runs in a single thread, meaning it can execute only one task at a time. When you run a script, it processes statements line by line — this is called synchronous execution.

For example:

console.log("Start");
console.log("Processing...");
console.log("End");

Output:

Start  
Processing...  
End

Everything happens in sequence. But what if one of those steps takes a long time — like fetching data from an external API? That would block the rest of the code from running.

The Problem with Synchronous Code

Let’s see what happens with a simulated delay:

console.log("Start");

function wait(ms) {
  const start = Date.now();
  while (Date.now() - start < ms) {
    // blocking the main thread
  }
}

wait(3000);
console.log("End");

This will freeze your app for 3 seconds before printing End. During that time, nothing else can run — not even UI updates. This blocking behavior makes synchronous code unsuitable for real-world applications that deal with slow tasks.

Enter Asynchronous JavaScript

Asynchronous code allows JavaScript to delegate long-running tasks — like network requests or timers — to the browser or Node.js background APIs. When the task completes, it notifies JavaScript to continue execution.

For example:

console.log("Start");

setTimeout(() => {
  console.log("Async task complete");
}, 2000);

console.log("End");

Output:

Start  
End  
Async task complete

Even though setTimeout() was called before End, the program didn’t wait — it moved on and came back later when the async task was done.

The Event Loop and Task Queue

The event loop is the core mechanism that enables this behavior. It constantly checks if the call stack (where functions run) is empty. When it is, the event loop takes the next callback or Promise resolution from the task queue and executes it.

This process ensures that asynchronous operations don’t block the main thread while still executing in the correct order when ready.

Callbacks: The First Asynchronous Pattern

Before Promises existed, developers used callbacks — functions passed as arguments to be executed once a task finished.

Example:

function getData(callback) {
  setTimeout(() => {
    callback("Data loaded!");
  }, 2000);
}

getData((message) => {
  console.log(message);
});

While this works, multiple async tasks often led to nested callbacks — a messy structure known as callback hell:

getData((data) => {
  processData(data, (processed) => {
    saveData(processed, (result) => {
      console.log(result);
    });
  });
});

This is hard to read, debug, and maintain. To solve this, JavaScript introduced Promises, which make async code cleaner and more predictable.


Promises Explained

To overcome the chaos of callback hell, Promises were introduced in ES6 (ECMAScript 2015) as a cleaner and more manageable way to handle asynchronous operations. Promises allow you to write async code that’s both readable and predictable.

What Is a Promise?

A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Think of it as a placeholder for a value that will be available in the future.

A Promise has three states:

  1. Pending – the initial state; the operation is still in progress.

  2. Fulfilled – the operation completed successfully, and a value is available.

  3. Rejected – the operation failed, and an error occurred.

Creating a Promise

You can create a Promise using the Promise constructor, which takes a function with two parameters: resolve and reject.

const myPromise = new Promise((resolve, reject) => {
  let success = true;

  setTimeout(() => {
    if (success) {
      resolve("Data fetched successfully!");
    } else {
      reject("Error fetching data!");
    }
  }, 2000);
});

Here, resolve() means the operation succeeded, while reject() indicates it failed.

Using .then() and .catch()

Once a Promise is created, you can handle its result using .then() for success and .catch() for errors.

myPromise
  .then((message) => {
    console.log(message);
  })
  .catch((error) => {
    console.error(error);
  });

Output (after 2 seconds):

Data fetched successfully!

If success were false, it would print:

Error fetching data!

Chaining Promises

You can chain multiple .then() calls to perform sequential asynchronous operations without nesting callbacks.

fetchUser()
  .then((user) => fetchPosts(user.id))
  .then((posts) => displayPosts(posts))
  .catch((error) => console.error(error));

Each .then() returns a new Promise, allowing smooth chaining and better readability.

Example with simulation:

function fetchUser() {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ id: 1, name: "Alice" }), 1000);
  });
}

function fetchPosts(userId) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(["Post 1", "Post 2", "Post 3"]), 1000);
  });
}

function displayPosts(posts) {
  console.log("User posts:", posts);
}

fetchUser()
  .then((user) => fetchPosts(user.id))
  .then((posts) => displayPosts(posts))
  .catch((err) => console.error("Error:", err));

Output:

User posts: [ 'Post 1', 'Post 2', 'Post 3' ]

The .finally() Method

The .finally() method runs after a Promise is settled, regardless of whether it was fulfilled or rejected. It’s useful for cleanup actions.

myPromise
  .then((msg) => console.log(msg))
  .catch((err) => console.error(err))
  .finally(() => console.log("Operation finished."));

Output:

Data fetched successfully!
Operation finished.

Promises made asynchronous JavaScript far more manageable — but they can still become verbose when dealing with multiple dependent async calls. That’s where async/await comes in to simplify things even further.


Async/Await Simplified

While Promises made asynchronous code easier to manage, chaining multiple .then() calls could still become cluttered. To make async code even more readable and synchronous-looking, async and await were introduced in ES2017 (ES8).

They’re built on top of Promises, not a replacement — just a cleaner way to work with them.

The async Keyword

When you declare a function with the async keyword, it automatically returns a Promise. Inside that function, you can use the await keyword to pause execution until a Promise is resolved or rejected.

Example:

async function sayHello() {
  return "Hello, world!";
}

sayHello().then((message) => console.log(message));

Output:

Hello, world!

Even though sayHello() looks like it returns a simple string, it actually returns a Promise that resolves to "Hello, world!".

The await Keyword

The await keyword can only be used inside an async function. It tells JavaScript to wait for a Promise to settle (either fulfilled or rejected) before continuing.

Let’s rewrite the previous Promise example with async/await:

Using Promises:

function getData() {
  return new Promise((resolve) => {
    setTimeout(() => resolve("Data loaded!"), 2000);
  });
}

getData().then((data) => console.log(data));

Using async/await:

async function fetchData() {
  const result = await getData();
  console.log(result);
}

fetchData();

Output (after 2 seconds):

Data loaded!

This looks much cleaner and easier to read — almost like synchronous code.

Handling Errors with try...catch

When using async/await, you can handle errors with the familiar try...catch syntax.

async function fetchData() {
  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
    const data = await response.json();
    console.log("Post title:", data.title);
  } catch (error) {
    console.error("Error fetching data:", error);
  }
}

This makes async error handling more intuitive compared to chaining .catch() with Promises.

Running Multiple Async Operations in Parallel

If you have multiple independent async tasks, you can run them in parallel using Promise.all().

async function loadMultiple() {
  const [user, posts] = await Promise.all([
    fetch("https://jsonplaceholder.typicode.com/users/1").then((res) => res.json()),
    fetch("https://jsonplaceholder.typicode.com/posts?userId=1").then((res) => res.json())
  ]);

  console.log("User:", user.name);
  console.log("Posts count:", posts.length);
}

loadMultiple();

This will fetch both the user and posts at the same time, improving performance significantly.

Using Promise.race() for the Fastest Response

Sometimes you only care about the first resolved Promise — for instance, when fetching data from multiple mirrors or APIs.

async function fetchFastest() {
  const result = await Promise.race([
    fetch("https://jsonplaceholder.typicode.com/posts/1"),
    fetch("https://jsonplaceholder.typicode.com/posts/2"),
  ]);

  const data = await result.json();
  console.log("Fastest result:", data);
}

fetchFastest();

Promise.race() returns the result of whichever Promise finishes first (fulfilled or rejected).

Summary

  • async makes a function return a Promise.

  • await pauses execution until the Promise resolves.

  • try...catch helps handle async errors cleanly.

  • Promise.all() runs async operations in parallel.

  • Promise.race() resolves with the fastest result.

With these tools, you can write asynchronous code that’s simple, elegant, and easy to maintain.


Real-World Examples

Now that you understand how Promises and async/await work, let’s apply them to some real-world scenarios. These examples demonstrate how asynchronous programming helps manage API calls, handle multiple requests, and simplify data workflows in JavaScript.

Example 1: Fetching Data from an API with Promises

Let’s start with the classic way of fetching data using the Fetch API and Promises.

fetch("https://jsonplaceholder.typicode.com/posts/1")
  .then((response) => {
    if (!response.ok) {
      throw new Error("Network response was not ok");
    }
    return response.json();
  })
  .then((data) => {
    console.log("Post Title:", data.title);
  })
  .catch((error) => {
    console.error("Error fetching post:", error);
  });

Output:

Post Title: sunt aut facere repellat provident occaecati excepturi optio reprehenderit

This works perfectly, but notice the multiple .then() calls — which can become repetitive for more complex workflows.

Example 2: Fetching Data with Async/Await

Here’s the same example rewritten with async/await, making it much cleaner and easier to follow:

async function fetchPost() {
  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
    if (!response.ok) {
      throw new Error("Network response was not ok");
    }
    const data = await response.json();
    console.log("Post Title:", data.title);
  } catch (error) {
    console.error("Error fetching post:", error);
  }
}

fetchPost();

No .then() chains, just readable, synchronous-style code — yet still asynchronous under the hood.

Example 3: Sequential Async Operations

Sometimes you need to perform multiple async actions one after another — for example, fetching a user, then their posts.

async function fetchUserAndPosts() {
  try {
    const userResponse = await fetch("https://jsonplaceholder.typicode.com/users/1");
    const user = await userResponse.json();

    const postsResponse = await fetch(`https://jsonplaceholder.typicode.com/posts?userId=${user.id}`);
    const posts = await postsResponse.json();

    console.log("User:", user.name);
    console.log("Number of Posts:", posts.length);
  } catch (error) {
    console.error("Error fetching user or posts:", error);
  }
}

fetchUserAndPosts();

Output:

User: Leanne Graham  
Number of Posts: 10

This runs each step in sequence — waiting for one operation to finish before moving to the next.

Example 4: Running Async Tasks in Parallel

If the tasks don’t depend on each other, you can improve performance using Promise.all().

async function fetchParallelData() {
  try {
    const [users, posts, comments] = await Promise.all([
      fetch("https://jsonplaceholder.typicode.com/users").then((res) => res.json()),
      fetch("https://jsonplaceholder.typicode.com/posts").then((res) => res.json()),
      fetch("https://jsonplaceholder.typicode.com/comments").then((res) => res.json()),
    ]);

    console.log(`Users: ${users.length}, Posts: ${posts.length}, Comments: ${comments.length}`);
  } catch (error) {
    console.error("Error loading data:", error);
  }
}

fetchParallelData();

Output:

Users: 10, Posts: 100, Comments: 500

This executes all three API calls simultaneously — saving significant time compared to running them one by one.

Example 5: Mixing Async Functions and Regular Logic

You can freely combine synchronous and asynchronous logic in your app.

async function calculateAndFetch() {
  const number = 42;
  console.log("Calculating...");

  const square = number * number;
  console.log("Square:", square);

  const response = await fetch("https://jsonplaceholder.typicode.com/posts/2");
  const data = await response.json();

  console.log("Fetched Title:", data.title);
}

calculateAndFetch();

Output:

Calculating...
Square: 1764
Fetched Title: qui est esse

This flexibility makes async/await perfect for blending business logic, calculations, and asynchronous operations in a readable way.

These examples show how asynchronous JavaScript powers real-world web development — from fetching APIs to managing concurrent operations — all while keeping code clean and easy to maintain.


Common Mistakes and Best Practices

Even though Promises and async/await simplify asynchronous programming, they can still lead to subtle bugs and performance issues if used incorrectly. Let’s go over the most common mistakes developers make — and how to avoid them.

🧩 Mistake 1: Forgetting to Handle Errors

Problem:
A common pitfall is ignoring errors or forgetting to wrap async code in a try...catch block.

async function fetchUser() {
  const response = await fetch("https://wrong-url.com/api/user"); // Error!
  const data = await response.json(); // This line never runs
  console.log(data);
}

fetchUser(); // Unhandled promise rejection

Fix:
Always wrap await calls in try...catch, or use .catch() on the returned Promise.

async function fetchUser() {
  try {
    const response = await fetch("https://wrong-url.com/api/user");
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error("Failed to fetch user:", error);
  }
}

Best Practice:
👉 Treat async errors like synchronous ones — always handle them gracefully.

⚙️ Mistake 2: Using await Inside Loops

Problem:
Using await inside a loop causes each iteration to wait for the previous one to finish — slowing things down unnecessarily.

async function fetchPostsSequential() {
  const ids = [1, 2, 3];
  for (const id of ids) {
    const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
    const data = await res.json();
    console.log(data.title);
  }
}

This will take around 3 × network latency time to finish.

Fix:
Run all requests in parallel using Promise.all().

async function fetchPostsParallel() {
  const ids = [1, 2, 3];
  const promises = ids.map((id) =>
    fetch(`https://jsonplaceholder.typicode.com/posts/${id}`).then((res) => res.json())
  );
  const results = await Promise.all(promises);
  results.forEach((post) => console.log(post.title));
}

Best Practice:
👉 Use Promise.all() for independent async tasks to improve performance.

⚠️ Mistake 3: Ignoring Unhandled Promise Rejections

If a Promise rejects and you don’t handle it, it can crash your app (especially in Node.js).

Fix:
Always attach a .catch() to Promises or wrap async functions in error-handling middleware (if using frameworks like Express).

process.on("unhandledRejection", (error) => {
  console.error("Unhandled Promise rejection:", error);
});

Best Practice:
👉 Always ensure every Promise chain or async function has an error handler.

💡 Mistake 4: Mixing then() and await Unnecessarily

You can use .then() with await, but mixing both styles makes code confusing.

Problem:

async function getData() {
  const data = await fetch("https://jsonplaceholder.typicode.com/posts/1").then((res) =>
    res.json()
  );
  console.log(data);
}

Fix:
Stick to one style — preferably async/await for cleaner code.

async function getData() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
  const data = await response.json();
  console.log(data);
}

Best Practice:
👉 Choose one pattern (preferably async/await) and stay consistent throughout your project.

🧠 Mistake 5: Not Using Promise.allSettled() for Multiple Results

Promise.all() fails if any of the Promises reject — even if others succeed. If you want to handle all outcomes, use Promise.allSettled().

const urls = [
  "https://jsonplaceholder.typicode.com/posts/1",
  "https://invalid-url.com/posts",
  "https://jsonplaceholder.typicode.com/posts/2",
];

async function fetchAll() {
  const results = await Promise.allSettled(
    urls.map((url) => fetch(url).then((res) => res.json()))
  );

  results.forEach((result) => {
    if (result.status === "fulfilled") {
      console.log("Success:", result.value.title);
    } else {
      console.warn("Failed:", result.reason);
    }
  });
}

fetchAll();

Best Practice:
👉 Use Promise.allSettled() when you want to process all results — both successes and failures.

⚡ Mistake 6: Forgetting to Return Promises

When using async functions or Promises inside other functions, forgetting to return them can lead to unexpected behavior.

Problem:

function getData() {
  fetch("https://jsonplaceholder.typicode.com/posts/1")
    .then((res) => res.json())
    .then((data) => console.log(data));
}

await getData(); // ❌ This won't wait because getData() doesn't return a Promise

Fix:
Return the Promise properly.

function getData() {
  return fetch("https://jsonplaceholder.typicode.com/posts/1").then((res) => res.json());
}

await getData(); // ✅ Works as expected

✅ Best Practices Summary

Do’s Don’ts
Use try...catch for error handling Ignore rejected Promises
Use Promise.all() for parallel tasks Use await inside loops
Use Promise.allSettled() for mixed outcomes Mix .then() and await unnecessarily
Return Promises from async functions Forget to return Promises
Keep async code consistent and readable Nest callbacks or Promises deeply

Mastering these best practices ensures your asynchronous JavaScript code stays fast, reliable, and maintainable — whether you’re building small utilities or large-scale web applications.


Conclusion and Next Steps

Asynchronous programming is one of the most important concepts in modern JavaScript development. It allows your code to stay responsive, handle multiple tasks efficiently, and create smooth user experiences without blocking the main thread.

In this tutorial, you learned:

  • The difference between synchronous and asynchronous JavaScript.

  • How the event loop and callbacks enable non-blocking behavior.

  • How to use Promises for cleaner async control flow.

  • How async/await simplifies asynchronous code into a more readable, synchronous-like format.

  • How to handle real-world use cases such as API calls, sequential and parallel requests.

  • The common mistakes to avoid and best practices for writing efficient async code.

By understanding and applying these concepts, you’ll be able to confidently manage asynchronous operations — whether you’re fetching data from APIs, reading files, or performing background computations in Node.js or the browser.

🚀 Next Steps

To deepen your understanding, check out these related tutorials on Djamware.com:

  • Mastering Mongoose Schemas, Models, and Validation in Node.js

  • 10 Useful Python Scripts to Automate Your Daily Tasks

  • Everything You Need to Know About Embeddings and Vector Databases

Continue experimenting with async patterns — such as Promise.any(), AbortController, and streaming responses — to handle more advanced scenarios in your web or Node.js applications.

You can find the full source code on our GitHub.

That's just the basics. If you need more deep learning about JavaScript, you can take the following cheap course:

Thanks!