DEV Community

The Practical Developer
The Practical Developer

Posted on Originally published at practicaldevblog.blogspot.com

JavaScript Async/Await Explained: From Callbacks to Clean Code

Introduction

JavaScript often has to wait — for a network request, a file, or a timer. The old way to handle waiting was nested callbacks, which quickly became hard to read. Promises improved that. Async/await makes it read almost like normal, top-to-bottom code.

If you understand promises and one keyword pair, async and await, you can write most asynchronous JavaScript you will need day to day.

The basic pattern

A function marked async always returns a promise. Inside it, await pauses that function until a promise settles, without blocking the rest of the program.

async function getUser(userId) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  );
  const user = await response.json();
  return user;
}
getUser(1).then((user) => console.log(user.name));
Enter fullscreen mode Exit fullscreen mode

Read it like normal code: fetch the user, wait for the JSON, return the user. The await lines are where the waiting happens, and they are easy to spot.

Handling errors with try/catch

Without error handling, a failed request becomes an unhandled promise rejection. Wrap awaited calls in try/catch, just like synchronous code.

async function getUserSafely(userId) {
  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/users/${userId}`
    );
    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error("Could not load user:", error.message);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two habits matter here. First, check response.ok, because fetch only rejects on a network failure, not on a 404 or 500. Second, return a safe fallback, like null, so the caller can handle the missing data cleanly.

Sequential vs parallel

Awaiting in a loop runs tasks one after another. That is correct when one task depends on the previous one, but slow when the tasks are independent.

// Sequential: second request waits for the first
const user1 = await getUser(1);
const user2 = await getUser(2);
// Parallel: both start at once, then wait for both
const [userA, userB] = await Promise.all([getUser(1), getUser(2)]);
Enter fullscreen mode Exit fullscreen mode

Use Promise.all when the tasks do not depend on each other. Note that if any promise in Promise.all rejects, the whole thing rejects, so handle errors for the group, not just for one item.

Common beginner mistakes

  • Forgetting await, then wondering why a variable holds a promise instead of data.
  • Using await inside a normal function instead of an async function.
  • Awaiting inside a loop when the tasks are independent — use Promise.all instead.
  • Forgetting error handling, so one failed request crashes the flow silently.

Takeaway

Mark the function async, await the promises you need, handle errors with try/catch, and use Promise.all for independent work. That covers most real async code, without callback nesting.

Originally published at https://practicaldevblog.blogspot.com/2026/10/javascript-async-await-explained.html

Top comments (0)