Skip to main content
TF
7 min readArticle

async/await vs Promises in JavaScript — Full Breakdown

TF
ToolsFuel Team
Web development tools & tips
JavaScript code on a laptop screen showing async functions

Photo by Unsplash on Unsplash

The Thing Nobody Tells You About async/await

I spent a good chunk of time early in my JavaScript career thinking async/await and Promises were two different approaches to handling asynchronous code. I'd see one codebase using `.then().catch()` chains and another using `async/await`, and I'd wonder which team was doing it right.

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

A Promise is an object representing an operation that hasn't completed yet. It can be in one of three states: pending (the operation is still running), fulfilled (it completed successfully with a value), or rejected (it failed with an error).

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

Here's that same fetch chain rewritten with async/await:

```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

Both Promises and async/await have subtle behaviors that catch developers off guard. Here are the ones I've hit personally.

**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

My general take after writing a lot of async JavaScript: default to async/await for most things. It's more readable, error handling is more natural with try/catch, and debugging is easier because stack traces are more useful.

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

One historical limitation of async/await was that you couldn't use `await` at the top level of a module — you had to wrap everything in an async function. That changed with top-level await, introduced in ES2022 and supported in Node.js 14.8+ and all modern browsers.

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

Let me leave you with a pattern I actually use in production for API calls that need retry logic and proper error handling. It combines everything we've discussed:

```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