async/await vs Promises in JavaScript — Full Breakdown
Photo by Unsplash on Unsplash
Table of Contents
The Thing Nobody Tells You About async/await
Here's the thing they don't say clearly enough in tutorials: async/await isn't an alternative to Promises. It's syntactic sugar built on top of Promises. An `async` function always returns a Promise. The `await` keyword is just a cleaner way to wait for a Promise to resolve. Under the hood, the JavaScript engine is still creating and chaining Promises — you're just writing it differently.
That said, the difference in how you write it matters a lot in practice. The two approaches have different ergonomics, different error handling patterns, and different trade-offs in readability. Understanding both means you can pick the right one for each situation instead of just defaulting to whichever syntax you learned first.
Let me walk through what's actually happening, because once you see it clearly, all the confusing edge cases start making sense.
Promises: What They Are and How They Work
You create a Promise like this:
```javascript const myPromise = new Promise((resolve, reject) => { // do something async setTimeout(() => { resolve('done!'); // or: reject(new Error('something went wrong')); }, 1000); }); ```
The function you pass to `new Promise()` runs immediately and synchronously. You call `resolve(value)` when the async operation succeeds, and `reject(error)` when it fails.
To consume a Promise, you chain `.then()` for success and `.catch()` for errors:
```javascript myPromise .then(result => console.log(result)) // 'done!' .catch(err => console.error(err)); ```
The power of Promises is chainability. Each `.then()` can return a new Promise, and the chain waits for each one before proceeding:
```javascript fetch('/api/user') .then(response => response.json()) .then(user => fetch(`/api/posts/${user.id}`)) .then(response => response.json()) .then(posts => console.log(posts)) .catch(err => console.error('something failed:', err)); ```
This works. But as the chain gets longer, it starts to feel like callback hell with extra steps. The values from earlier steps aren't easily accessible in later steps without variable declaration tricks. Error handling with `.catch()` is straightforward when one handler covers the whole chain, but gets complicated when you need different handling at different points.
If you want to dig deeper into how the event loop and microtasks actually schedule promise resolution, the MDN Promise reference is the canonical write-up.
async/await: Same Thing, Better Syntax
```javascript async function getUserPosts() { const userResponse = await fetch('/api/user'); const user = await userResponse.json(); const postsResponse = await fetch(`/api/posts/${user.id}`); const posts = await postsResponse.json(); console.log(posts); } ```
This reads like synchronous code. Each `await` pauses execution within the function until the Promise resolves, then continues to the next line. The value from each `await` is immediately available on the next line — no more nested callbacks or variable hoisting tricks.
`async` before a function declaration does two things: it makes the function return a Promise automatically (even if you don't explicitly return one), and it enables the use of `await` inside that function. You can't use `await` outside an `async` function (except at the top level of ES modules, which I'll get to).
For error handling, `async/await` uses `try/catch`, which most developers already know from synchronous code:
```javascript async function getUserPosts() { try { const userResponse = await fetch('/api/user'); const user = await userResponse.json(); const postsResponse = await fetch(`/api/posts/${user.id}`); const posts = await postsResponse.json(); return posts; } catch (err) { console.error('request failed:', err); throw err; // re-throw if you want the caller to handle it too } } ```
The `try/catch` block catches any rejected Promise awaited within it. Clean, familiar, and easy to reason about.
The Gotchas That Trip People Up
**Sequential vs parallel execution.** This is the big one with async/await. When you write:
```javascript const a = await fetchA(); const b = await fetchB(); ```
You're making those requests sequentially — B doesn't start until A completes. If A and B are independent, you're wasting time. Use `Promise.all()` when they can run in parallel:
```javascript const [a, b] = await Promise.all([fetchA(), fetchB()]); ```
This starts both requests simultaneously and waits for both to complete. If either rejects, the whole thing rejects.
**fetch() doesn't throw on HTTP errors.** This is specific to the Fetch API but it trips up a lot of people. `fetch()` only rejects if there's a network failure. A 404 or 500 response resolves the Promise — you have to check `response.ok` manually:
```javascript const response = await fetch('/api/data'); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } const data = await response.json(); ```
**Unhandled Promise rejections.** If you call an async function but don't await it and don't attach a `.catch()`, any rejection becomes an unhandled Promise rejection — which in Node.js (since v15) crashes your process. Always either await async calls or explicitly handle their rejection.
**async functions in array methods.** `Array.map()` with an async callback returns an array of Promises, not an array of values. This works:
```javascript const results = await Promise.all(ids.map(id => fetchItem(id))); ```
This does NOT work the way you'd expect:
```javascript // Wrong: results is an array of Promises const results = ids.map(async id => await fetchItem(id)); ```
**Promise.allSettled() vs Promise.all().** `Promise.all()` fails fast — one rejection cancels everything. `Promise.allSettled()` waits for all Promises regardless and gives you an array of `{status, value/reason}` objects. Use `allSettled` when you want all results even if some fail.
When to Use Which
But Promises (or a mix of both) make more sense in specific situations:
**Use `.then()/.catch()` when:** - You're not inside an async function and don't want to (or can't) make the parent async - You're chaining a one-liner where the then/catch pattern is actually more concise - You're working in older codebases where async/await isn't available (pre-ES2017 without Babel) - You're building utilities that return Promises for consumers to use however they want
**Use async/await when:** - You have sequential async operations where each step depends on the previous - You want readable try/catch error handling - You're doing anything moderately complex — multiple awaits, conditional logic, loops - You're writing code other developers will maintain
**Mix both when:** - You need `Promise.all()`, `Promise.race()`, `Promise.allSettled()`, or `Promise.any()` — these static methods don't have direct async/await equivalents, so you use them inside async functions:
```javascript async function loadDashboard() { const [user, settings, notifications] = await Promise.all([ fetchUser(), fetchSettings(), fetchNotifications() ]); return { user, settings, notifications }; } ```
This is the pattern I reach for most often — async/await for the overall structure, Promise.all() for parallel operations within it.
Top-Level await and Where You Can Use It
In a `.mjs` file or any file treated as an ES module (with `"type": "module"` in package.json), you can now write:
```javascript // Works in ES modules const config = await fetch('/config.json').then(r => r.json()); console.log(config); ```
This is especially useful for initialization code — loading configuration, connecting to databases, fetching initial data. Before top-level await, you'd wrap this in an IIFE (Immediately Invoked Function Expression) like `(async () => { ... })()` which was technically fine but looked weird.
Note that top-level await blocks the module from finishing initialization until the awaited Promise resolves. If your module exports something, importing modules will wait for the top-level await to complete. That's intentional and useful, but worth knowing.
For CommonJS modules (using `require()`, the traditional Node.js format), top-level await isn't available. You're still in IIFE territory there.
If you want to explore related JavaScript fundamentals, our post on what is an API and how it works covers how fetch() fits into the broader picture of browser-to-server communication — which is what most async JavaScript is actually doing.
Real-World Pattern: Solid API Calls
```javascript async function apiCall(url, options = {}, retries = 3) { for (let attempt = 1; attempt <= retries; attempt++) { try { const response = await fetch(url, options);
if (!response.ok) { const error = new Error(`HTTP ${response.status}`); error.status = response.status; // Don't retry client errors (4xx) if (response.status >= 400 && response.status < 500) throw error; throw error; // Server errors (5xx) will be retried }
return await response.json(); } catch (err) { if (attempt === retries) throw err; // Wait before retrying: 1s, 2s, 4s (exponential backoff) await new Promise(r => setTimeout(r, 1000 * 2 ** (attempt - 1))); } } }
// Usage try { const data = await apiCall('/api/endpoint'); console.log(data); } catch (err) { console.error('All retries failed:', err.message); } ```
This pattern handles network failures, server errors with exponential backoff, and client errors (which shouldn't be retried). The mix of async/await for the main flow and `new Promise()` for the delay timeout shows both syntaxes working together naturally.
If you need to generate test data or UUIDs for your API payloads while building, the ToolsFuel UUID generator and JSON formatter are both useful during API development.
Frequently Asked Questions
Is async/await better than Promises?
async/await isn't better or worse — it's a different syntax for the same underlying Promises. async functions always return Promises, and await is just a cleaner way to wait for a Promise to resolve. For most code, async/await is more readable and easier to debug. But Promise methods like Promise.all(), Promise.race(), and Promise.allSettled() are still necessary for parallel operations and have no direct async/await equivalent. For checking JSON responses from async API calls, the [ToolsFuel JSON formatter](/tools/json-formatter) is faster than copy-pasting into the browser console.
Can I use await outside an async function?
Yes, but only at the top level of ES modules (files using import/export syntax). This is called top-level await, supported in Node.js 14.8+ and modern browsers. In traditional CommonJS modules (using require()) or inside regular functions, await must be inside an async function. If you try to use await in a non-async function, you'll get a SyntaxError.
Why does my Promise.all() fail when one request fails?
Promise.all() uses a fail-fast strategy — if any Promise in the array rejects, the whole thing rejects immediately with that error. If you want to wait for all Promises regardless of individual failures and collect all results (including which ones failed), use Promise.allSettled() instead. It returns an array of objects with a status field ('fulfilled' or 'rejected') and a value or reason.
How do I run async operations in parallel instead of sequentially?
Two consecutive awaits run sequentially by default: the second one doesn't start until the first completes. To run in parallel, start all the Promises simultaneously and then await them together using Promise.all(): const [result1, result2] = await Promise.all([asyncFn1(), asyncFn2()]). This starts both operations at the same time and waits for both to complete, often significantly reducing total time.
Why doesn't fetch() throw an error on 404 or 500?
The fetch() API only rejects its Promise on network-level failures (no connection, DNS failure, etc.). HTTP error responses like 404 Not Found or 500 Internal Server Error are technically valid HTTP responses, so fetch() resolves them. You have to check the response.ok property (which is true for 200-299 status codes) and throw an error manually if needed.
What happens if I don't handle a rejected Promise?
In modern Node.js (v15+), an unhandled Promise rejection crashes the process with an exit code. In browsers, it fires an unhandledrejection event and prints a warning to the console. Always either await a Promise inside a try/catch, attach a .catch() handler, or pass it to Promise.all() (which you then handle). Never fire-and-forget an async function without handling its potential rejection.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools