Async/Await Syntax in JavaScript

Async/Await Syntax in JavaScript

Async/Await Syntax in JavaScript

I’ll never forget how much cleaner my code became the day I started using async/await seriously. I had spent years chaining .then() calls and nesting callbacks, and suddenly I could write asynchronous code that reads top to bottom like synchronous code. In this article, I want to give you a genuinely deep understanding of async/await — not just the syntax, but what’s actually happening behind the scenes so you can debug it confidently.

What Async/Await Actually Is

async/await is syntactic sugar built on top of Promises. It doesn’t replace Promises — it makes working with them feel synchronous while keeping all the non-blocking behavior underneath. Every async function always returns a Promise, and await can only be used inside an async function (or at the top level of a module).

The Basics

Declaring an Async Function

async function greet() {
  return "Hello!";
}

greet().then((message) => console.log(message)); // Hello!

Even though I just return a plain string, JavaScript automatically wraps it in a resolved Promise because the function is declared async.

Using await

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function run() {
  console.log("Start");
  await delay(1000);
  console.log("1 second later");
}

run();

await pauses execution of the async function until the Promise it’s waiting on settles — but critically, it does not block the rest of the program. Other code continues running while the async function is paused.

Fetching Data — The Classic Use Case

async function getUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }
  const user = await response.json();
  return user;
}

getUser(1)
  .then((user) => console.log(user))
  .catch((error) => console.error(error));

Error Handling with try/catch

This is one of the biggest quality-of-life improvements async/await gave me — I get to use normal try/catch instead of chaining .catch().

async function loadUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error(`Status: ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error("Failed to load user:", error.message);
    return null;
  }
}

Sequential vs. Parallel Execution

This is the mistake I see most often (and made myself constantly when I was learning): running independent async operations sequentially when they could run in parallel.

The Slow Way (Sequential)

async function loadAllSequential() {
  const user = await fetchUser();     // waits ~1s
  const posts = await fetchPosts();   // waits another ~1s
  const comments = await fetchComments(); // waits another ~1s
  // Total: ~3 seconds
  return { user, posts, comments };
}

If fetchPosts() doesn’t depend on the result of fetchUser(), awaiting them one after another wastes time.

The Fast Way (Parallel with Promise.all)

async function loadAllParallel() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments(),
  ]);
  // Total: ~1 second (all three run concurrently)
  return { user, posts, comments };
}

I start all three requests at once, then wait for all of them to finish together. This is one of the highest-impact performance optimizations I make in real applications.

Promise.allSettled — When Some Requests Might Fail

async function loadWithFallback() {
  const results = await Promise.allSettled([
    fetchUser(),
    fetchPosts(),
    fetchComments(),
  ]);

  results.forEach((result, i) => {
    if (result.status === "fulfilled") {
      console.log(`Request ${i} succeeded:`, result.value);
    } else {
      console.error(`Request ${i} failed:`, result.reason);
    }
  });
}

I use Promise.allSettled when I want all results, regardless of whether some fail — Promise.all would reject the entire batch the moment any single promise rejects.

Looping with Async/Await

Sequential Loop (One at a Time, Intentionally)

async function processInOrder(ids) {
  for (const id of ids) {
    const result = await processItem(id);
    console.log(`Processed ${id}:`, result);
  }
}

I use this pattern when each item genuinely needs to be processed one after another — for example, when rate limits or ordering matter.

Parallel Loop with map + Promise.all

async function processAllAtOnce(ids) {
  const results = await Promise.all(ids.map((id) => processItem(id)));
  return results;
}

A Mistake I See Constantly: forEach with Async

async function brokenLoop(ids) {
  ids.forEach(async (id) => {
    const result = await processItem(id);
    console.log(result);
  });
  console.log("Done!"); // This logs BEFORE any of the items are actually processed!
}

forEach doesn’t wait for the async callbacks it invokes — it fires them all and moves on immediately. I always use a for...of loop or Promise.all with .map() instead of forEach when async behavior matters.

Internal Working: How Async/Await Actually Executes

This is the part that took me the longest to truly internalize, but once it clicked, debugging async code became far easier.

An async function, when called, starts executing synchronously just like a normal function — right up until it hits the first await. At that point, execution of the function is suspended, and control returns immediately to the caller. The rest of the async function’s body is scheduled to resume as a microtask once the awaited Promise settles.

console.log("1");

async function demo() {
  console.log("2");
  await null;
  console.log("3");
}

demo();
console.log("4");

// Output order: 1, 2, 4, 3

Here’s what happens step by step:

  1. console.log("1") runs synchronously.
  2. demo() is called; it runs synchronously up to console.log("2").
  3. await null immediately suspends demo, scheduling the rest of the function as a microtask, and control returns to the caller.
  4. console.log("4") runs synchronously, since we’re back in the main script.
  5. After the synchronous code finishes, the event loop processes the microtask queue, resuming demo and logging "3".

This is exactly why async/await is described as sitting on top of the microtask queue — the same queue used by native Promise .then() callbacks. await doesn’t magically make things synchronous; it’s the engine transparently rewriting the function into a Promise chain and pausing/resuming at each await point, using the same continuation mechanism generator functions use internally.

Under the Hood: It’s Basically Generators + Promises

Conceptually (not literally, but close), async function foo() { await bar(); } behaves like:

function foo() {
  return bar().then(() => {
    // continue after await
  });
}

Every await is essentially a .then() continuation point, chained automatically by the engine. This mental model has helped me reason about ordering issues far more reliably than just memorizing rules.

Top-Level Await

Modern JavaScript modules (ESM) support await directly at the top level, without wrapping it in an async function.

// data.mjs
const response = await fetch("https://api.example.com/config");
export const config = await response.json();

I use this for module initialization that genuinely needs to happen before the rest of the module can be used — but I’m careful with it, since it can block the loading of any module that imports this one.

Practical, Real-World Patterns

Timeout Wrapper for Fetch Requests

async function fetchWithTimeout(url, timeoutMs = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      throw new Error("Request timed out");
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

Retry with Async/Await

async function retry(fn, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === attempts - 1) throw error;
      await new Promise((r) => setTimeout(r, 500 * (i + 1)));
    }
  }
}

Queueing Async Tasks with Concurrency Limits

async function processWithLimit(items, limit, worker) {
  const results = [];
  const executing = [];

  for (const item of items) {
    const p = worker(item).then((res) => results.push(res));
    executing.push(p);

    if (executing.length >= limit) {
      await Promise.race(executing);
      executing.splice(executing.findIndex((e) => e === p), 1);
    }
  }

  await Promise.all(executing);
  return results;
}

I use patterns like this when hitting an API with hundreds of requests but need to respect rate limits.

Best Practices

Common Mistakes to Avoid

  1. Forgetting await — calling an async function without await just gives you a pending Promise, not the resolved value.
  2. Sequential awaiting of independent operations — wastes time; use Promise.all.
  3. Swallowing errors silently in catch blocks without logging or rethrowing.
  4. Mixing .then() and await in confusing ways within the same function — pick one style per function for clarity.
  5. Using async in the top-level Array.forEach callback, expecting it to wait — it won’t.

Debugging Tips

Security Considerations

When using await fetch() for external requests, I always validate and sanitize response data before using it, especially before injecting anything into the DOM, to avoid XSS. I also make sure timeouts and abort logic are in place so a hanging external service can’t stall my application indefinitely.

FAQs

Q: Does await block the entire JavaScript thread? A: No. It only pauses the execution of the current async function. The rest of the program, including the UI and other code, continues running normally.

Q: What does an async function return if I don’t explicitly return anything? A: It returns a Promise that resolves to undefined.

Q: Can I use await outside an async function? A: Only at the top level of an ES module. Anywhere else, it’s a SyntaxError.

Q: Is async/await faster than .then() chains? A: Performance is essentially identical since async/await compiles down to the same Promise machinery. The benefit is readability, not raw speed.

Summary and Key Takeaways

Once I really understood what was happening under the hood — that await points are just automatic .then() continuations scheduled on the microtask queue — writing and debugging asynchronous JavaScript stopped feeling like guesswork.

References

Exit mobile version