JavaScriptMedium

How does async/await work in JavaScript?

01

The Short Answer

async/await is syntactic sugar over Promises that lets you write asynchronous code in a sequential, top-to-bottom style. Under the hood it still uses Promises, but the resulting code is significantly easier to read, write, and reason about.

02

How It Works

Before async/await, handling multiple dependent async operations meant chaining .then() calls. While functional, deeply nested chains became difficult to follow. async/await flattens this into code that reads like synchronous logic.

Consider fetching a user and then fetching their orders — two dependent async calls.

promises.tstypescript
function getUserOrders(userId: string) {
  return fetchUser(userId)
    .then(user => fetchOrders(user.id))
    .then(orders => console.log(orders))
    .catch(err => console.error(err));
}
async-await.tstypescript
async function getUserOrders(userId: string) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    console.log(orders);
  } catch (err) {
    console.error(err);
  }
}

Key insight

The async/await version reads top-to-bottom. Each await pauses only this function until the Promise resolves, then execution continues to the next line.

03

The async Keyword

Placing async before a function declaration does two things:

What async does

  • The function always returns a `Promise` — even if you return a plain value, it gets wrapped automatically
  • You can now use the `await` keyword inside the function body
04

The await Keyword

await pauses execution of the enclosing async function until the awaited Promise settles. It then unwraps the resolved value and returns it.

Important distinction

await only pauses the function it lives in — it does not block the main thread. Other code outside this function continues to execute while the function is suspended.

05

Error Handling

With raw Promises you chain .catch(). With async/await you use try...catch — the same pattern you already know from synchronous code. This makes error handling consistent and predictable.

error-handling.tstypescript
async function loadData() {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error('Failed to load data:', error);
    throw error; // re-throw if caller needs to handle it
  }
}
06

Async Functions and .then() Behavior

Every async function automatically returns a Promise. You never need to manually call resolve() or reject() — the language handles it for you. If the function completes successfully, the returned Promise resolves with whatever value you return. If the function throws an error, the Promise rejects with that error.

The rules

  • Every `async` function returns a Promise — always
  • A successful `return` triggers `.then()` on the caller side
  • A thrown error triggers `.catch()` on the caller side
  • You do NOT need to manually call `resolve()` or `reject()`
async-then-behavior.tstypescript
// The async function returns a Promise<Order[]>
async function getUserOrders(userId: string) {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  return orders; // This value becomes the resolved value of the Promise
}

// .then() fires when the Promise resolves (function returns successfully)
getUserOrders("123")
  .then((orders) => console.log("Orders:", orders))
  .catch((err) => console.error("Something failed:", err));

What's happening under the hood

When getUserOrders returns orders, JavaScript wraps it as Promise.resolve(orders). When it throws (or an await rejects), JavaScript wraps it as Promise.reject(error). That's why .then() and .catch() work on the result.

Here's the equivalent code written manually with new Promise() — this is exactly what the async keyword does for you automatically:

equivalent-promise.tstypescript
// This is what async/await does behind the scenes
function getUserOrders(userId: string): Promise<Order[]> {
  return new Promise(async (resolve, reject) => {
    try {
      const user = await fetchUser(userId);
      const orders = await fetchOrders(user.id);
      resolve(orders); // Same as "return orders" in async version
    } catch (err) {
      reject(err); // Same as throwing an error in async version
    }
  });
}

Notice how verbose the manual version is. The async keyword eliminates all of that boilerplate — return becomes resolve() and throw becomes reject() automatically.

more-examples.tstypescript
// Example 1: return value → .then() receives it
async function getGreeting(): Promise<string> {
  return "Hello, World!"; // Wrapped in Promise.resolve("Hello, World!")
}

getGreeting().then((msg) => console.log(msg)); // "Hello, World!"

// Example 2: thrown error → .catch() receives it
async function failingTask(): Promise<never> {
  throw new Error("Something broke"); // Wrapped in Promise.reject(error)
}

failingTask().catch((err) => console.error(err.message)); // "Something broke"

// Example 3: awaited rejection propagates to .catch()
async function fetchProfile(id: string) {
  const res = await fetch(`/api/users/${id}`); // If this rejects...
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json(); // ...this never runs, Promise rejects
}

fetchProfile("456")
  .then((profile) => console.log(profile))
  .catch((err) => console.error("Caught:", err)); // catches both fetch failure & HTTP error

Key takeaway

An async function implicitly wraps its return value in Promise.resolve() and any thrown error in Promise.reject(). You can consume the result with either await or .then()/.catch() — both work because the result is always a Promise.

07

Common Mistakes

🐌

Sequential awaits when parallel is possible

Using `await` in a loop when requests don't depend on each other forces them to run one-by-one. If each takes 1s and you have 5, that's 5 seconds total.

Use `Promise.all()` to run independent requests concurrently — same 5 requests finish in ~1 second.

🔄

Forgetting that async functions return Promises

Calling an `async` function without `await` or `.then()` means you get a Promise object, not the resolved value. This leads to subtle bugs where data appears as `[object Promise]`.

Always `await` or `.then()` the result of an async function when you need the resolved value.

parallel-vs-sequential.tstypescript
// ❌ Slow — sequential (5 seconds for 5 users)
for (const id of userIds) {
  const user = await fetchUser(id);
  results.push(user);
}

// ✅ Fast — parallel (≈1 second for 5 users)
const results = await Promise.all(
  userIds.map(id => fetchUser(id))
);
08

Why Interviewers Ask This

Nearly every frontend app fetches data from APIs. Interviewers use this question to gauge whether you understand JavaScript's async execution model, can handle errors gracefully, and know when to run operations sequentially vs in parallel. Demonstrating awareness of common pitfalls signals you can write reliable, performant async code in production.

Quick Revision Cheat Sheet

async: Makes a function return a Promise and enables `await` inside it

await: Pauses the `async` function until the Promise resolves, returns the value

Error handling: Use `try...catch` instead of `.catch()` chains

Parallel execution: Use `Promise.all()` for independent async operations

Does not block: `await` only pauses the enclosing function, not the main thread