If you've written any modern code, you've used async and await. They're so common now that it's easy to forget they solve a specific problem, and that there's more going on under the hood.

This post covers what async/await actually is, why it exists, and how it can shows up in code.

The Cost of Blocking I/O

A program can spend a surprising amount of time waiting: waiting for a database to respond, waiting for a file to finish reading, waiting for an external API to answer. This waiting is called I/O (input/output), and it's far slower than anything happening inside the CPU itself.

The classical approach is to just wait, call a function, block execution until it returns, then move on. This is simple to reason about, but it's wasteful. While your program sits idle waiting for a network response, the CPU could be doing other useful work: handling another user's request, processing another task, anything.

For a single script running once, blocking is fine. For a server handling thousands of concurrent requests, blocking on I/O means each waiting request ties up resources that could serve someone else. Scale that up, and blocking I/O becomes the reason your server can't handle more traffic.

Synchronous vs. Asynchronous

At a glance, here's how the two compare:

Synchronous

Asynchronous

Execution

Runs line by line. Each operation finishes before the next one starts

An operation can start, then hand off control, letting other code run while it finishes in the background

While waiting on I/O

Blocks the thread until the result comes back

Doesn't block. The thread is free to do other work in the meantime

Best suited for

CPU-bound work: computation, data processing

I/O-bound work: network calls, file access, database queries, timers

Example

const data = readFile(path)

const data = await readFile(path)

From Callbacks to Async/Await

Async/await didn't appear out of nowhere. It's the third iteration of a solution to the same problem, and understanding the first two explains why it was created.

  • Callbacks were the original fix. Instead of blocking, you pass a function to run later, once the operation finishes. This works, but nested callbacks get unreadable fast, especially when one async operation depends on the result of another. Each new step adds another layer of indentation. Developers called this "callback hell," and it wasn't an exaggeration: error handling had to be repeated at every level, and the actual sequence of operations got buried in nested braces.

getUser(id, (err, user) => {
  if (err) return handleError(err);
  getPosts(user.id, (err, posts) => {
    if (err) return handleError(err);
    getComments(posts[0].id, (err, comments) => {
      if (err) return handleError(err);
      console.log(comments);
    });
  });
});
  • Promises cleaned this up. A promise represents a value that will exist eventually, either resolved (success) or rejected (failure). Instead of nesting, you chain .then() calls, which avoids the nested indentation and centralizes error handling with a single .catch(). This was a real improvement, but chains of .then() are still not quite how humans think about sequential steps. Conditional logic, loops, and try/catch-style error handling remain awkward to express in a chain.

getUser(id)
  .then((user) => getPosts(user.id))
  .then((posts) => getComments(posts[0].id))
  .then((comments) => console.log(comments))
  .catch((err) => handleError(err));
  • Async/await is syntactic sugar on top of promises. It lets you write asynchronous code that reads like synchronous code, top to bottom, with familiar control flow (if statements, loops, try/catch), while the underlying mechanism is still promise-based. This is the key insight: async/await didn't replace promises, it made them ergonomic.

try {
  const user = await getUser(id);
  const posts = await getPosts(user.id);
  const comments = await getComments(posts[0].id);
  console.log(comments);
} catch (err) {
  handleError(err);
}

How Async/Await Works

A Quick Analogy

Picture ordering a coffee ☕

  • Synchronous code is a coffee shop with one barista who serves one customer at a time, start to finish. Nobody else can order until that person's drink is done.

  • A promise is like getting a buzzer: you order, step aside, and the barista moves on to the next customer. When your buzzer goes off, you know your coffee's ready and go pick it up.

  • Async/await is the same buzzer system, just written so the code reads as if you never stepped aside. await getCoffee() reads top to bottom: order, wait, drink. It's still a promise doing the non-blocking work underneath; async/await just removes the need to think in callbacks or buzzers.

A few things are worth being precise about:

  • An async function always returns a promise. Even if you write return 5, the actual return value is a promise that resolves to 5. Callers need to await it or handle it as a promise.

  • await pauses the function, not the thread. When you await a promise, the function's execution pauses at that line, but control returns to the event loop, which is free to do other work, like handling another request. Once the awaited promise resolves, the function resumes from where it left off.

  • This is concurrency, not parallelism. Async/await doesn't spin up new threads or run code simultaneously on multiple cores. In JavaScript specifically, it works within a single-threaded event loop: while one function is "waiting," other code gets a turn to run. This is why async/await is effective for I/O-bound work (like waiting for network or disk access) but offers zero benefit for CPU-bound tasks (heavy computation). A tight loop doing math will still block everything else, await or not.

Tracing It Through

That ordering can feel abstract, so here's what actually happens, step by step, for a small example:

function fetchUser() {
  return Promise.resolve({ name: 'John Doe' });
}

async function getUser() {
  console.log('2: inside getUser');
  const user = await fetchUser();
  console.log('4: user fetched:', user);
}

console.log('1: start');
getUser();
console.log('3: end of script');

The output is 1: start, 2: inside getUser, 3: end of script, 4: user fetched: { name: 'John Doe' } in that order, even though line 4 appears third inside the getUser() function, and even though fetchUser() resolves instantly with no real delay at all.

Here's why: getUser() runs synchronously until it hits await fetchUser(). Even though the promise is already resolved by the time await sees it, await still defers the rest of the function to the microtask queue rather than continuing immediately. That's what lets the rest of the script, line 3, run first. Only once the current synchronous code finishes does the microtask queue get processed, and the rest of getUser() runs, printing line 4 last.

Here's that same trace laid out step by step:

event-loop-trace.png

One clarification worth keeping in mind: promises and async/await continuations are always scheduled as microtasks, not macrotasks. Macrotasks come from a separate source, Web APIs or Node's timers and I/O bindings, things like setTimeout, setInterval, setImmediate, or network callbacks. This trace doesn't use any of those, so the macrotask queue stays completely empty throughout. Every bit of scheduling you see here happens on the microtask queue.

Async/Await in Practice

Understanding the mechanics is one thing. Here's how async/await actually shows up in day-to-day code:

API route handlers. The most common pattern: an HTTP handler awaits a database query or an external API call before responding.

app.get('/user/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user);
});

While this request waits on the database, the server is free to handle other incoming requests. This is the entire point.

Parallelizing independent work. A frequent problem is awaiting operations sequentially when they don't depend on each other:

// Slow: each await blocks the next from starting
const user = await getUser(id);
const posts = await getPosts(id);
const comments = await getComments(id);

If posts and comments don't depend on user, this needlessly serializes three round-trips. Promise.all fixes it by kicking off all three at once and waiting for them together:

const [user, posts, comments] = await Promise.all([
  getUser(id),
  getPosts(id),
  getComments(id),
]);

This single change can cut response time roughly to the length of the slowest call instead of the sum of all three.

Note: Promise.all is fail-fast. If any one promise rejects, Promise.all rejects immediately with that error, even if the others are still pending, they keep running in the background, but you never get their results. If you need every result regardless of individual failures (say, three independent services where one failing shouldn't crash the entire request), use Promise.allSettled instead. It always resolves, returning a { status, value } or { status, reason } object per promise (array of those objects), so you can decide what to do with partial failures yourself. To learn more about the allSettled method, check the MDN Docs.

Error handling. One of the biggest readability wins with async/await is returning to traditional try/catch blocks. Instead of chaining .catch() methods onto promises, a single try/catch wraps your async flow cleanly:

try {
  const result = await riskyOperation();
} catch (err) {
  logger.error('Operation failed', err);
}

The catch here is that a rejected promise not wrapped in try/catch, and not otherwise handled, becomes an unhandled rejection. In Node.js, unhandled rejections can crash the process depending on version and configuration. In production code, every await should have a clear path for handling failure, even if that path is just letting a wrapping error-handling layer catch it.

Common Pitfalls

  • Forgetting await. Calling an async function without await doesn't throw an error: it just returns a pending promise immediately, and the rest of your code runs with that pending promise instead of the value you expected. This is a common source of silent bugs, especially with linters not configured to catch it. If you're on TypeScript, rules like @typescript-eslint/no-floating-promises catch many of these automatically.

  • async inside .forEach don't wait for execution to complete. It doesn't wait for promises returned by its callback, it fires them all and moves on. If you need to await each iteration in sequence, a for...of loop is the right tool. If you want them running concurrently, map to an array of promises and Promise.all it or Promise.allSettled() if you don't want a single failure to reject the entire batch.

const userIds = [1, 2, 3];

function fetchUser(id) {
  return new Promise((resolve) => setTimeout(() => resolve({ id }), 500));
}

// [-] forEach doesn't wait for the async callback
userIds.forEach(async (id) => {
  const user = await fetchUser(id);
  console.log(`Fetched user ${id}`);
});
console.log('Done!'); // logs before any user is actually fetched

// [+] Sequential: for...of actually awaits each iteration
for (const id of userIds) {
  await fetchUser(id);
}

// [+] Concurrent: map to promises, then Promise.all (or allSettled)
await Promise.all(userIds.map((id) => fetchUser(id)));
  • Top-level await is allowed in modern ES modules, but it turns the entire module evaluation asynchronous. Any module that imports it is forced to pause execution until that await settles. This can silently delay loading, alter initialization order, introduce failures during import, and break environments expecting synchronous modules. Frequently, the intended design was to keep the await inside an async function and trigger it intentionally.

// config.js
const fetchConfig = () => new Promise(resolve => setTimeout(resolve, 1000));

console.log('config.js: fetching config...');
export const config = await fetchConfig(); // top-level await
console.log('config.js: ready');

// main.js
import { config } from './config.js';

console.log('main.js: started');

If fetchConfig() takes a second to resolve, the output is:

config.js: fetching config...
config.js: ready       (1 second later)
main.js: started

main.js never even reaches its first console.log until config.js finishes awaiting. The import itself pauses on it. Nothing in main.js looks asynchronous, but importing a module with a top-level await silently makes the importer wait too.

  • return await inside try/catch isn't just stylistic. Outside a try/catch, return somePromise() and return await somePromise() behave the same for the caller, so skipping the extra await is a common micro-optimization. Inside a try/catch, it matters: if you return somePromise() without awaiting it, a rejection happens after the function has already returned, so the local catch never sees it, the error surfaces wherever the caller is handling the returned promise instead. Awaiting it keeps the rejection inside the function where your catch block can actually catch it, and preserves a stack trace that points back to where the error happened, rather than an unrelated call site further up the chain.

// [-] BROKEN: The catch block NEVER runs
async function fetchUserData() {
  try {
    return apiCall(); // Rejection happens outside this scope!
  } catch (err) {
    console.error('Caught locally:', err); // Never fires
  }
}

// [+] CORRECT: The rejection is caught locally
async function fetchUserData() {
  try {
    return await apiCall(); // Pause here so catch can intercept
  } catch (err) {
    console.error('Caught locally:', err); // Works as expected!
  }
}
  • Hanging promises can leak memory. An await on a fetch or a database call that never resolves, because the other end hung, or a connection dropped silently, keeps that request's memory and any resources it's holding alive indefinitely. Over time, unhandled hanging promises will quietly drain system memory. Always pair external I/O operations with a timeout or cancellation signal (e.g., using AbortController or database query timeouts) so requests fail gracefully instead of hanging forever.

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

try {
  const res = await fetch(url, { signal: controller.signal });
  return await res.json();
} finally {
  clearTimeout(timeout);
}

This guarantees the awaited call fails fast instead of hanging forever, and the finally block ensures the timeout itself always gets cleaned up.

The True Value of Async/Await

Async/await earns its keep whenever code involves I/O: network requests, database access, file system operations, timers. It's not doing anything useful for pure, synchronous computation, wrapping a CPU-bound loop in async doesn't make it faster or non-blocking, it just adds promise overhead.

The real value of async/await isn't performance by itself, that efficiency comes from the engine's underlying event loop and non-blocking I/O model. The value is that it allows you to express asynchronous logic in a clean, linear style that mimics synchronous code, without sacrificing the concurrency benefits that made promises worth having in the first place.