Async/Await vs Promises in JavaScript
Async/await is syntactic sugar over Promises, not a different mechanism. How each looks in practice, and when to still reach for raw Promises.
Promises are JavaScript’s built-in representation of a value that isn’t available yet but will be, resolved through .then() and .catch() chains. Async/await is syntax built directly on top of Promises that lets you write asynchronous code that reads like synchronous code, without changing what’s actually happening underneath. Neither is a competing technology — async/await is Promises, just with different syntax for consuming them.
How Promises work
A Promise represents one of three states: pending, fulfilled, or rejected. You attach callbacks with .then() for success and .catch() for failure, and chain multiple asynchronous steps by returning a new Promise from inside a .then():
fetch("/api/user")
.then((res) => res.json())
.then((user) => fetch(`/api/orders/${user.id}`))
.then((res) => res.json())
.then((orders) => console.log(orders))
.catch((err) => console.error(err));
This solved a real problem — deeply nested callbacks, sometimes called “callback hell,” where each async step indented further than the last. Promise chains flatten that nesting, and a single .catch() at the end handles errors from any step in the chain. Promises are built on the JavaScript event loop: the callback passed to .then() doesn’t run immediately, it’s queued as a microtask once the Promise settles.
How async/await works
Marking a function async means it always returns a Promise, and lets you use await inside it to pause execution at a Promise until it settles, without a .then() callback:
async function getOrders() {
const userRes = await fetch("/api/user");
const user = await userRes.json();
const ordersRes = await fetch(`/api/orders/${user.id}`);
return ordersRes.json();
}
This is the exact same underlying mechanism as the Promise chain above — await is pausing on a Promise and resuming when it resolves, just as .then() does — but it reads top to bottom like ordinary synchronous code, which is easier to follow once a function chains more than two or three async steps.
Under the hood, await doesn’t block the thread. It suspends the async function and lets other queued work run, resuming the function’s execution once the awaited Promise settles — the same non-blocking behavior Promises already had, presented with different syntax.
Error handling: try/catch vs .catch()
Promise chains handle errors with .catch(), which catches a rejection from anywhere earlier in the chain. Async/await uses ordinary try/catch, which reads more naturally alongside synchronous error handling you’d already write elsewhere:
async function getOrders() {
try {
const userRes = await fetch("/api/user");
const user = await userRes.json();
return await fetch(`/api/orders/${user.id}`);
} catch (err) {
console.error(err);
}
}
One easy mistake: forgetting await before a function call that returns a rejected Promise inside a try block means the rejection never actually reaches the catch — the try block only catches errors from operations it actually awaited.
Running things in parallel
A common performance mistake with async/await is awaiting independent operations one after another, which serializes work that didn’t need to be serial:
// Sequential — waits for each before starting the next
const a = await fetchA();
const b = await fetchB();
// Parallel — both start immediately
const [a, b] = await Promise.all([fetchA(), fetchB()]);
Promise.all() (and its relatives, Promise.allSettled() for when you want results even if some reject, and Promise.race() for the first to settle) is still the right tool for running independent async operations concurrently — async/await doesn’t replace these APIs, it just gives you a cleaner way to consume their results once they resolve.
Async/await vs Promises
Promises (.then()/.catch()) | Async/await | |
|---|---|---|
| Underlying mechanism | Same — both use the Promise object | Same — both use the Promise object |
| Reads as | Chained callbacks | Sequential, synchronous-looking code |
| Error handling | .catch() | try/catch |
| Best for simple, single-step async | Concise for one-off .then() calls | Slightly more verbose for a single call |
| Best for multi-step async | Nesting grows with each step | Stays flat regardless of step count |
| Running tasks in parallel | Promise.all() directly | Still needs Promise.all(), awaited |
| Function signature | Any function can return a Promise | Requires the async keyword |
When to still reach for raw Promises
Async/await is generally easier to read for multi-step sequences, but plain Promise methods are still the right choice for functional-style chaining, for libraries that expose a Promise-returning API you’re just passing through, or for coordinating a set of concurrent operations with Promise.all() — which you’ll typically call with await in front of it anyway rather than treating the two as alternatives. In practice, most modern JavaScript and TypeScript code uses async/await for control flow and drops down to Promise combinators specifically when it needs to run things concurrently.
The takeaway
Async/await doesn’t replace Promises — it’s syntax layered on top of the same mechanism, designed to make multi-step asynchronous code read like ordinary sequential code and handle errors with familiar try/catch blocks. Use await for readability in sequential flows, but don’t forget Promise.all() when operations are actually independent — awaiting them one by one is a common and easy-to-miss performance regression.
Tagged
Keep reading
Takina · · 4 min read Understanding the JavaScript Event Loop
JavaScript is single-threaded, yet pages stay responsive. A clear tour of the call stack, task queue, and microtasks — with examples you can run.
Takina · · 4 min read TypeScript Abstract Classes, Explained
Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.
Takina · · 4 min read What Is a Lockfile? Reproducible Dependency Installs
A lockfile records the exact dependency versions your package manager resolved, so every install — from your laptop to CI — reproduces the same tree.