Home/Blog/Backend/Async/Await in JavaScript - How It Actually Works and Why It Exists
Backend12 min readAug 4, 2026

Async/Await in JavaScript - How It Actually Works and Why It Exists

How async/await works in JavaScript - async functions, await, error handling, parallel vs sequential execution, and the most common mistakes explained.

191 views1 likes0 comments0 bookmarks

If you have read the Promises blog and the Callbacks blog on codekk.dev, you already understand the problem JavaScript has been trying to solve. Async operations are everywhere, and writing clean code that handles them has always been harder than it should be.

Callbacks gave us a way to handle async results but turned into nested pyramids. Promises flattened that pyramid into a readable chain. Async/await takes it one step further - it makes async code look and feel like synchronous code, while still being completely non-blocking underneath.

This is the final piece of the JavaScript async series on codekk.dev. By the end of this blog, you will understand not just how to write async/await but what it is actually doing under the hood, why it behaves the way it does, and the mistakes that trip developers up in real projects.


What Async/Await Actually Is

Async/await is not a new async system. It is not replacing Promises or the event loop. It is syntax built directly on top of Promises that makes them easier to read and write.

Every async function returns a Promise. Every await expression pauses that function and waits for a Promise to settle. That is the entire model. Everything else follows from these two facts.

Js
// This async function
async function greet() {
  return "Hello";
}

// Is exactly the same as this
function greet() {
  return Promise.resolve("Hello");
}

// Both are used the same way
greet().then(message => console.log(message)); // "Hello"

The async keyword before a function does one thing - it makes that function always return a Promise. If you return a plain value, it gets wrapped in Promise.resolve() automatically. If you throw an error, it gets wrapped in Promise.reject().


The await Keyword - What It Actually Does

await can only be used inside an async function. It pauses the execution of that function until the Promise it is waiting for settles, then resumes with the resolved value.

Js
async function fetchUser() {
  const response = await fetch("https://api.example.com/users/1");
  const user = await response.json();
  console.log(user);
}

Here is what happens line by line:

  1. fetch() is called - it returns a Promise immediately

  2. await pauses fetchUser and hands control back to the caller

  3. The event loop is free to do other work

  4. When the fetch Promise resolves, fetchUser resumes with the response

  5. response.json() is called - it also returns a Promise

  6. await pauses again, event loop is free again

  7. When the json Promise resolves, fetchUser resumes with the parsed data

  8. console.log(user) runs

The function pauses twice. But the rest of your application keeps running during those pauses. This is not blocking - it is the event loop working exactly as we covered in the JavaScript Event Loop blog.

[IMAGE: Diagram showing async function execution - function starts, hits await, pauses and hands control to event loop (shown as circular arrow), event loop handles other tasks, Promise resolves, function resumes from where it paused. Labels: "Function pauses here" at await, "Resumes here" when Promise resolves]


Async/Await vs Promise Chains - The Same Thing Written Differently

The best way to understand async/await is to see it next to the equivalent Promise chain. They are doing exactly the same thing.

Promise chain version:

Js
function loadDashboard(userId) {
  return fetchUser(userId)
    .then(user => {
      return fetchOrders(user.id)
        .then(orders => ({ user, orders }));
    })
    .then(({ user, orders }) => {
      return fetchRecommendations(user.preferences)
        .then(recommendations => ({ user, orders, recommendations }));
    })
    .then(({ user, orders, recommendations }) => {
      renderDashboard(user, orders, recommendations);
    })
    .catch(err => {
      console.error("Dashboard failed to load:", err);
    });
}

Async/await version:

Js
async function loadDashboard(userId) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    const recommendations = await fetchRecommendations(user.preferences);
    renderDashboard(user, orders, recommendations);
  } catch (err) {
    console.error("Dashboard failed to load:", err);
  }
}

Same operations. Same order. Same error handling. The async/await version reads like a simple list of steps from top to bottom. No chaining, no nesting, no callbacks. This is the whole point.

Under the hood, the JavaScript engine converts the async/await version into something similar to the Promise chain. You are writing cleaner code - the engine handles the Promise machinery for you.


Error Handling With Try/Catch

In Promise chains, you use .catch() for error handling. With async/await, you use the standard JavaScript try/catch that you already know from synchronous code.

Js
async function getUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      throw new Error(`HTTP error - status: ${response.status}`);
    }

    const user = await response.json();
    return user;
  } catch (err) {
    console.error("Failed to fetch user:", err.message);
    return null;
  }
}

When any await inside the try block rejects, execution jumps to the catch block. Exactly like a synchronous try/catch.

Handling errors for specific steps differently:

Sometimes you want to handle errors at individual steps rather than catching everything in one place:

Js
async function processOrder(orderId) {
  let order;

  try {
    order = await fetchOrder(orderId);
  } catch (err) {
    // This specific error we can recover from
    console.log("Order not found, creating new one");
    order = await createDefaultOrder();
  }

  // This error we want to propagate up
  const payment = await processPayment(order.total);
  return payment;
}

You can nest try/catch blocks or use separate try/catch for each step when different errors need different handling.


Async/Await With Multiple Promises - Parallel vs Sequential

This is one of the most important things to understand with async/await, and a very common source of performance bugs.

Sequential - each waits for the previous:

Js
async function loadData() {
  const users = await fetchUsers();       // waits ~300ms
  const products = await fetchProducts(); // waits ~300ms after users
  const orders = await fetchOrders();     // waits ~300ms after products
  // Total time: ~900ms
}

This is fine when each call depends on the result of the previous one. But if these three calls are completely independent of each other, you are making them wait in line for no reason.

Parallel - all start at the same time:

Js
async function loadData() {
  const [users, products, orders] = await Promise.all([
    fetchUsers(),
    fetchProducts(),
    fetchOrders()
  ]);
  // Total time: ~300ms (waits for the slowest one)
}

Promise.all starts all three requests simultaneously. You await the combined Promise that resolves when all three finish. The total time is roughly equal to the slowest single request instead of the sum of all three.

This is one of the biggest performance wins you can get with very little code change. If you have multiple independent async operations, run them in parallel.

[IMAGE: Timeline diagram showing two approaches - top row shows sequential awaits as three blocks one after another totaling 900ms. Bottom row shows Promise.all as three overlapping blocks all starting at the same time totaling 300ms. Labels: "Sequential - 900ms total" and "Parallel with Promise.all - 300ms total"]

When to use sequential vs parallel:

Js
// Sequential is correct here - second depends on first
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id); // needs user.id

// Parallel is correct here - neither depends on the other
const [profile, settings] = await Promise.all([
  fetchProfile(userId),
  fetchSettings(userId)
]);

The rule is simple: if a request needs data from a previous request, run them sequentially. If they are independent, run them in parallel.


Async Functions in Different Contexts

Async/await works in regular functions, arrow functions, and methods. The syntax is slightly different in each case but the behavior is the same.

Regular function:

Js
async function fetchData() {
  const data = await getData();
  return data;
}

Arrow function:

Js
const fetchData = async () => {
  const data = await getData();
  return data;
};

Class method:

Js
class UserService {
  async getUser(id) {
    const user = await db.users.findById(id);
    return user;
  }

  async createUser(data) {
    const user = await db.users.create(data);
    return user;
  }
}

In Next.js Server Components - which we covered in detail in the SSR, CSR, SSG, and ISR blog:

Jsx
// Server Component - async works directly at the component level
export default async function ProductsPage() {
  const products = await fetch("https://api.example.com/products")
    .then(r => r.json());

  return (
    <div>
      {products.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  );
}

In Express route handlers - from the Node.js and Express blog:

Js
app.get("/users/:id", async (req, res) => {
  try {
    const user = await db.users.findById(req.params.id);
    if (!user) return res.status(404).json({ error: "User not found" });
    res.json(user);
  } catch (err) {
    res.status(500).json({ error: "Internal server error" });
  }
});

Top Level Await

Traditionally, await could only be used inside an async function. If you tried to use it at the top level of a module, you would get a syntax error.

Modern JavaScript (ES2022) introduced top-level await which allows you to use await directly in ES modules without wrapping it in an async function.

Js
// In an ES module (.mjs or with "type": "module" in package.json)
const data = await fetch("https://api.example.com/config").then(r => r.json());
console.log(data); // works at the top level

This is particularly useful in Node.js scripts and configuration files. In Next.js and most modern build setups, top-level await works out of the box.


Common Mistakes With Async/Await

Forgetting await:

Js
async function getUser(id) {
  const user = fetchUser(id); // missing await
  console.log(user); // logs Promise { <pending> } not the actual user
  return user;
}

Without await, you get the Promise object itself, not the resolved value. TypeScript catches this in many cases - another reason to use TypeScript as we covered in the TypeScript vs JavaScript blog.

Using await inside forEach:

Js
// This does NOT work as expected
async function processOrders(orders) {
  orders.forEach(async (order) => {
    await processOrder(order); // these all fire simultaneously and forEach doesn't wait
  });
  console.log("Done"); // runs before any orders are processed
}

// Use for...of instead
async function processOrders(orders) {
  for (const order of orders) {
    await processOrder(order); // waits for each one sequentially
  }
  console.log("Done"); // runs after all orders are processed
}

// Or if you want parallel processing
async function processOrders(orders) {
  await Promise.all(orders.map(order => processOrder(order)));
  console.log("Done"); // runs after all orders are processed in parallel
}

forEach does not care about Promises. It fires all the async callbacks and moves on. Use for...of for sequential processing or Promise.all with .map() for parallel processing.

Not handling errors:

Js
// If this rejects, the error is swallowed silently
async function riskyOperation() {
  const data = await fetchSomething();
  return data;
}

// Always wrap in try/catch or handle at the call site
async function safeOperation() {
  try {
    const data = await fetchSomething();
    return data;
  } catch (err) {
    console.error("Operation failed:", err);
    return null;
  }
}

Async functions in useEffect - the wrong way:

Js
// Wrong - useEffect callback cannot be async directly
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

// Correct - define async function inside and call it
useEffect(() => {
  async function load() {
    const data = await fetchData();
    setData(data);
  }
  load();
}, []);

We covered useEffect in detail in the useEffect blog. The async function pattern inside useEffect is one of the most common React mistakes developers hit when they first learn async/await.


How Async/Await Connects to the Event Loop

If you read the JavaScript Event Loop blog, you know that .then() callbacks go into the Microtask Queue - the high priority queue that runs before setTimeout callbacks.

await works through the same mechanism. When a function hits await, it pauses and registers the rest of the function as a microtask to run when the awaited Promise resolves. This is why async/await code has the same priority as Promise chains - because it is Promise chains, just with different syntax.

Js
async function example() {
  console.log("1 - Before await");
  await Promise.resolve();
  console.log("3 - After await");
}

example();
console.log("2 - After calling example");

// Output:
// 1 - Before await
// 2 - After calling example
// 3 - After await

When example() hits await, it pauses and the console.log("2 - After calling example") on the outer code runs first. Then the microtask queue runs and example() resumes with "3 - After await".

This is exactly the same behavior we traced through with .then() in the Promises blog - because the mechanism is identical.


Async/Await in Real Projects - Putting It All Together

Here is what a real-world async/await pattern looks like in a complete Node.js and Express API - combining everything from the backend series on codekk.dev:

Js
// Utility function for reusable async error handling
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Route handler - clean, readable, no try/catch clutter
app.get("/users/:id", asyncHandler(async (req, res) => {
  const user = await db.user.findUnique({
    where: { id: req.params.id },
    include: { posts: true }
  });

  if (!user) {
    return res.status(404).json({ error: "User not found" });
  }

  res.json(user);
}));

// Running multiple queries in parallel
app.get("/dashboard/:userId", asyncHandler(async (req, res) => {
  const { userId } = req.params;

  const [user, orders, notifications] = await Promise.all([
    db.user.findUnique({ where: { id: userId } }),
    db.order.findMany({ where: { userId }, take: 10 }),
    db.notification.findMany({ where: { userId, read: false } })
  ]);

  if (!user) {
    return res.status(404).json({ error: "User not found" });
  }

  res.json({ user, orders, notifications });
}));

// Global error handler catches anything that slips through
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: "Internal server error" });
});

The asyncHandler wrapper is a common pattern in Express - it wraps any async route handler and forwards any unhandled rejections to the next error handler. This removes the need for try/catch in every single route handler.


The Complete Picture - JavaScript Async From Start to Finish

Now that you have read through the entire async series on codekk.dev, here is how all the pieces fit together:

Callbacks - the original pattern. Functions passed to be called later. Still used everywhere for event listeners, array methods, and simple cases.

Promises - a cleaner API around callbacks. An object representing a future value. Chainable with .then() and .catch(). The foundation everything else builds on.

Async/Await - syntax sugar over Promises. Makes async code look synchronous. async functions always return Promises. await pauses execution until a Promise settles.

Event Loop - the underlying mechanism that makes all of this possible. Callbacks and microtasks get queued and run when the call stack is empty. None of this blocks - it all works through the queue system.

They are not competing concepts. They are layers of the same system, each one built on top of the previous, each one making async JavaScript a little cleaner to write.

[IMAGE: Layered architecture diagram showing four layers stacked - bottom layer "Event Loop" in blue, second layer "Callbacks" in orange, third layer "Promises" in yellow, top layer "Async/Await" in green. Arrow on the side labeled "Each layer builds on the one below it". This shows the complete async JavaScript picture]

Comments

Sign in to join the conversation

Related Articles

More insights you might enjoy

JavaScript Closures - The Concept Interviewers Actually Test
JavaScript

JavaScript Closures - The Concept Interviewers Actually Test

Closures are one of the most asked-about JavaScript concepts in interviews, and also one of the most misunderstood. This blog explains what a closure actually is, why functions can remember variables from outside their own scope, and where closures show up in real code

Vishal Vishwakarma·9 min
1461
Higher-Order Functions in JavaScript
JavaScript

Higher-Order Functions in JavaScript

Higher-order functions are one of the most important ideas in JavaScript, and you're probably already using them without realizing it. This blog explains what makes a function "higher-order," why they matter, and how methods like map, filter, and setTimeout all rely on this same simple idea.

Vishal Vishwakarma·7 min
4830
How React's useEffect Actually Works - And Why Developers Misuse It

How React's useEffect Actually Works - And Why Developers Misuse It

useEffect is one of the most used hooks in React and also one of the most misunderstood. This guide covers the dependency array, cleanup functions, the double render, and common mistakes that cause bugs.

Sadie Sink·8 min
3110
Node.js and Express.js - What They Are, How They Differ, and How They Work Together

Node.js and Express.js - What They Are, How They Differ, and How They Work Together

Node.js and Express.js are not the same thing. This guide explains what each does, how they work together, and covers routing, middleware, and error handling with real code.

Sadie Sink·9 min
2880
How the JavaScript Event Loop Actually Works - A Deep Dive

How the JavaScript Event Loop Actually Works - A Deep Dive

A complete deep dive into the JavaScript event loop - the call stack, callback queue, microtask queue, and how they work together to make async JavaScript possible.

Sadie Sink·11 min
2750
TypeScript vs JavaScript - What Is Actually Different and Should You Learn TypeScript

TypeScript vs JavaScript - What Is Actually Different and Should You Learn TypeScript

TypeScript and JavaScript are more related than most people think. This guide covers the real difference, core TypeScript features you need to know, and helps you decide whether to make the switch.

Sadie Sink·8 min
2650