5 async/await Mistakes That Slow Your JavaScript Code
Sequential awaits, await in forEach, missing Promise.all: these 5 async/await mistakes silently slow your JavaScript. Here's how to spot and fix each one.
On this page
If you've ever wondered why your dashboard takes 3 seconds to load when your API calls each only take 1 second, this post is for you.
async/await makes asynchronous code look clean. It also makes it very easy to accidentally run things in sequence that should run in parallel. Here are five mistakes that show up in real production code, with before/after examples and exactly how to fix each one.
- 1
Sequential Awaits When Tasks Are Independent
The most expensive performance mistake in async JavaScript. When tasks don't depend on each other, awaiting them one-by-one means 3 one-second calls take 3 seconds instead of 1.
javascript// ❌ Sequential (3 requests × 1 second = 3 seconds total) const user = await fetchUser(userId); const posts = await fetchPosts(userId); const stats = await fetchAnalytics(userId); // ✅ Parallel with Promise.all (~1 second, slowest request wins) const [user, posts, stats] = await Promise.all([ fetchUser(userId), fetchPosts(userId), fetchAnalytics(userId), ]);Promise.allkicks off all three requests simultaneously and waits for all of them to finish.- The total time equals the duration of the slowest request, not the sum of all requests.
- The rule: if Task B doesn't need Task A's result, don't
awaitTask A first.
- 2
await Inside forEach
forEachwas not designed to understand Promises. When you mark its callbackasync, it starts each promise but doesn't wait: execution continues immediately.javascript// ❌ await inside forEach (fire-and-forget) orderIds.forEach(async (id) => { await processOrder(id); // not waited for }); console.log('Done!'); // runs immediately, nothing has processed yet // ✅ Parallel: Promise.all + map await Promise.all(orderIds.map(id => processOrder(id))); console.log('Done!'); // waits for all orders // ✅ Sequential: for...of with await for (const id of orderIds) { await processOrder(id); }forEachcalls each callback and moves on, ignoring any returned Promise.- Use
for...ofwithawaitwhen tasks must run in order. - Use
Promise.all+mapwhen tasks can run in parallel.
- 3
Promise.all Without Handling Partial Failures
Promise.allis fast but unforgiving: if one promise rejects, the entire call rejects and you lose results from every promise that succeeded.javascript// ❌ One failure kills all three results const [user, posts, stats] = await Promise.all([ fetchUser(userId), fetchPosts(userId), fetchAnalytics(userId), // throws 500 → you get nothing ]); // ✅ Promise.allSettled (partial results on failure) const results = await Promise.allSettled([ fetchUser(userId), fetchPosts(userId), fetchAnalytics(userId), ]); const user = results[0].status === 'fulfilled' ? results[0].value : null; const posts = results[1].status === 'fulfilled' ? results[1].value : [];- Use
Promise.allwhen you need every result and a partial failure genuinely means you can't continue. - Use
Promise.allSettledwhen partial data is better than a full error: most dashboard UIs fall here.
- Use
- 4
The Silent Missing await
The most dangerous mistake: forgetting
awaiton an async function produces no error. The operation runs fire-and-forget and execution continues immediately.javascript// ❌ Missing await (validation runs but isn't waited for) async function createPost(data) { validatePost(data); // fires and is forgotten return await savePost(data); // runs before validation finishes } // ✅ With await (validation must complete before saving) async function createPost(data) { await validatePost(data); return await savePost(data); }- If
validatePostthrows, the rejection is silently swallowed andsavePostruns anyway. - Tests often miss this because they only check the happy path: the bug surfaces in production.
- TypeScript with
@typescript-eslint/no-floating-promisescatches missingawaitcalls statically.
Enable the ESLint rule that catches this automatically:
javascript — eslint.config.mjsimport tseslint from 'typescript-eslint'; export default tseslint.config({ rules: { '@typescript-eslint/no-floating-promises': 'error', }, }); - If
- 5
Awaiting a map That Returns Promises
When you use
asyncinside amapcallback,mapreturns an array of Promises, not resolved values. Awaiting the array itself resolves immediately, since it's not a Promise. Related to Mistake 2 (forEach) but with a different cause.javascript// ❌ await on the array (resolves immediately with [Promise, Promise, Promise]) const results = await items.map(async (item) => fetchData(item)); // results is [Promise {}, Promise {}, Promise {}], not actual values // ✅ Wrap with Promise.all (resolves all promises in the array) const results = await Promise.all( items.map(async (item) => fetchData(item)) ); // results is now the actual fetched valuesmapreturns a new array synchronously: it does not wait for async callbacks.- The outer
awaitresolves the array synchronously (arrays are not Promises). - The fix is always
await Promise.all(items.map(async item => fn(item))).
- 6
When Sequential Is the Right Choice
Not every situation calls for parallel execution. Sequential
awaitis the correct choice in specific scenarios:- Task B depends on Task A's result: fetch a user, then fetch their orders using the user ID.
- You are writing to a database where concurrent writes on the same record create race conditions.
- You are processing a queue where order matters and each item must complete before the next begins.
javascript// ✅ Sequential (fetchOrders needs userId from the user object) const user = await fetchUser(userId); const orders = await fetchOrders(user.id); // depends on user resultjavascript// ✅ Sequential queue (order matters, concurrent writes risk race conditions) for (const item of queue) { await processItem(item); }
Quick Decision Guide
Pick the right async pattern based on whether tasks depend on each other's results:
| Situation | Pattern to use |
|---|---|
| Independent tasks, need all results | await Promise.all([a(), b(), c()]) |
| Independent tasks, partial failure OK | await Promise.allSettled([a(), b()]) |
| Each result feeds the next task | Sequential await or for...of with await |
| Array of items processed in parallel | await Promise.all(items.map(async i => fn(i))) |
| Array processed one at a time | for (const item of items) { await fn(item); } |
What await Actually Does
Every mistake in this post comes from the same root cause: await looks like it pauses the program, but it only pauses one function. Getting the mental model right makes the bugs obvious instead of mysterious.
When the engine reaches an await, it suspends the current async function, wraps the awaited value in a resolved Promise if it is not one already, and returns control to the caller. The rest of the function body is scheduled as a microtask that runs once the awaited Promise settles.
That means an async function returns to its caller the moment it hits the first await, not when it finishes. Everything after the await runs later, on a separate turn of the event loop.
async function demo() {
console.log('1: runs synchronously');
const value = await 42; // not a Promise, but await still yields
console.log('3: resumed in a microtask', value);
}
demo();
console.log('2: the caller keeps running');
// Output order: 1, 2, 3Notice that await 42 still defers the rest of the function even though 42 is not a Promise. await always yields at least one microtask tick. This is why sprinkling await on synchronous values is never free, and why ordering bugs appear in code that looks perfectly linear.
The second consequence is the important one for performance. A Promise starts doing its work the moment it is created, not the moment you await it. Calling fetchUser(id) fires the request immediately. The await only decides when you collect the result.
This create-now, collect-later split is the single most useful thing to internalise. It is also the trick behind the fastest fix for sequential code: start every Promise first, then await them together.
// Both requests are already in flight before the first await runs
const userPromise = fetchUser(userId);
const postsPromise = fetchPosts(userId);
const user = await userPromise;
const posts = await postsPromise;
// Total time = the slower of the two, not the sumThis pattern is equivalent to Promise.all for timing, and some teams prefer it because each variable keeps its own name and type. The catch is error handling, which is covered further down: if userPromise rejects before you reach await postsPromise, the second rejection can go unhandled.
await in Loops vs Promise.all
Mistake 1 showed three hardcoded calls. The version that actually costs teams money is the same pattern hidden inside a loop, where the penalty scales with the size of the array.
A for...of loop with an await inside processes strictly one item at a time. Ten items at 200ms each take two full seconds, and nobody notices in development because the seed database has three rows.
const ids = [1, 2, 3, 4, 5];
const fetchOne = (id) =>
new Promise((resolve) => setTimeout(() => resolve(id * 2), 200));
// Sequential: 5 x 200ms
console.time('sequential');
const sequential = [];
for (const id of ids) {
sequential.push(await fetchOne(id));
}
console.timeEnd('sequential'); // sequential: ~1002ms
// Parallel: bounded by the slowest single call
console.time('parallel');
const parallel = await Promise.all(ids.map(fetchOne));
console.timeEnd('parallel'); // parallel: ~204msThe gap widens linearly with the array length. Here is what the same 200ms operation costs across common batch sizes, comparing a sequential loop, unbounded Promise.all, and a pool that caps concurrency at 10:
| Items | Sequential loop | Promise.all | Pool of 10 |
|---|---|---|---|
| 5 | ~1.0s | ~0.2s | ~0.2s |
| 25 | ~5.0s | ~0.2s | ~0.6s |
| 100 | ~20.0s | ~0.2s (100 sockets open) | ~2.0s |
| 1,000 | ~200.0s | Likely rate limited or out of memory | ~20.0s |
The last row is the reason unbounded Promise.all is not always the answer. Firing a thousand simultaneous requests will trip API rate limits, exhaust your database connection pool, or hit the operating system file descriptor limit long before it gives you a thousand-fold speedup.
There is also a subtle memory cost. Promise.all holds every resolved value in memory until the last one settles. Mapping a million database rows into a single Promise.all keeps all million results alive at once.
Limiting Concurrency Without a Library
The middle ground between one-at-a-time and all-at-once is a worker pool: run a fixed number of tasks concurrently, and start the next task as soon as a slot frees up. You do not need a dependency for this.
The implementation below keeps a shared cursor into the input array and spawns limit workers that each pull the next index until the array is exhausted. Results land in their original positions, so ordering is preserved.
export async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await fn(items[index], index);
}
}
const size = Math.min(limit, items.length);
await Promise.all(Array.from({ length: size }, worker));
return results;
}
// At most 10 requests in flight, no matter how long urls is
const bodies = await mapWithConcurrency(urls, 10, (url) =>
fetch(url).then((res) => res.text())
);Note that cursor++ is safe here despite looking like a shared-state race. JavaScript is single-threaded, and the increment happens synchronously between awaits, so no two workers can ever claim the same index.
Choosing the limit is an empirical exercise, not a formula. Start with the constraint you are protecting: your database pool size, the API's documented rate limit, or the number of sockets your host allows.
| Workload | Reasonable starting limit | Constraint to watch |
|---|---|---|
| Postgres queries | Pool size minus 2 | Connection pool exhaustion |
| Third-party REST API | Documented rate limit per second | 429 responses and IP bans |
| S3 or blob uploads | 10 to 20 | Upstream bandwidth |
| Local filesystem reads | Number of CPU cores | Event loop and disk queue depth |
| Internal microservice | 20 to 50 | Downstream service capacity |
Error Handling in async Functions
Async error handling looks familiar because it reuses try/catch, and that familiarity is exactly what hides the gaps. A try block only catches rejections from Promises you actually await inside it.
What try/catch Around await Actually Covers
The classic gap is returning a Promise instead of awaiting it. The function still resolves to the right value on the happy path, so tests pass, but the catch block is dead code.
// Broken: the rejection escapes to the caller, catch never runs
async function loadProfile(userId) {
try {
return fetchUser(userId); // returned, not awaited
} catch (error) {
return null; // unreachable
}
}
// Correct: await inside the try so the rejection is caught here
async function loadProfile(userId) {
try {
return await fetchUser(userId);
} catch (error) {
console.error('Profile load failed', error);
return null;
}
}This is why return await inside a try block is correct and not redundant. Outside a try, return await promise and return promise behave the same, which is why some older lint configs flagged it. Inside a try, dropping the await silently disables your error handling.
The same gap opens whenever a Promise is created inside a callback the try block does not await. A rejection thrown from a setTimeout callback, an event listener, or a forEach callback never reaches the surrounding try, because that code runs on a later tick with an empty call stack.
// The try block has already exited by the time the callback runs
try {
setTimeout(() => {
throw new Error('never caught here');
}, 0);
} catch (error) {
// unreachable
}Where You Put .catch Changes What It Catches
A .catch() handler only covers the part of the chain that appears before it. Attaching it directly to the fetching call leaves any transformation that follows unprotected.
// Covers fetchUser only. If u is null, the .then callback throws
// and that error is NOT handled by this catch.
const name = await fetchUser(id)
.catch(() => null)
.then((u) => u.profile.name);
// Covers the whole chain, including the transformation
const name = await fetchUser(id)
.then((u) => u.profile.name)
.catch(() => 'Anonymous');The rule is simple: put .catch() last, after every .then() whose failure you want to handle. If you need different recovery for different stages, use more than one .catch() and place each one immediately after the stage it protects.
Unhandled Rejections in Node.js
Since Node.js 15, an unhandled Promise rejection is treated as an uncaught exception and terminates the process by default. That is a deliberate improvement over the old behaviour, which printed a warning and carried on with corrupted state.
The trap is creating several Promises up front and awaiting them one by one. If the first one rejects, the function unwinds and the later awaits never execute, leaving those Promises with no handler attached.
// Trap: if a rejects, the await on b never runs and b's
// rejection is unhandled, which can crash the process
const a = riskyCall('a');
const b = riskyCall('b');
const resultA = await a;
const resultB = await b;
// Fix: hand every Promise to a combinator immediately
const [resultA, resultB] = await Promise.all([
riskyCall('a'),
riskyCall('b'),
]);
// Or keep both outcomes regardless of failure
const settled = await Promise.allSettled([
riskyCall('a'),
riskyCall('b'),
]);Promise.all attaches a handler to every Promise in the array the moment it is called, so a second, later rejection is absorbed rather than left dangling. It still rejects with the first failure only.
In long-running services, register a process-level handler so unhandled rejections are logged with context before the process exits. Log first, then exit: never swallow the error and keep serving traffic on unknown state.
process.on('unhandledRejection', (reason) => {
logger.fatal({ reason }, 'Unhandled promise rejection');
process.exit(1); // let the orchestrator restart a clean process
});Error propagation through async boundaries is a favourite interview topic precisely because it separates people who have shipped async code from people who have only read about it. The Node.js interview questions guide works through several variations of this exact scenario.
Race Conditions and Cancellation with AbortController
Parallelism introduces a class of bug that sequential code cannot have: two in-flight operations finishing in an order you did not expect. The canonical example is a search box that fires a request per keystroke.
Type re, then react. If the request for re is slower than the request for react, the stale results arrive last and overwrite the correct ones. The code is perfectly valid, and the UI is wrong.
// Race condition: a slower earlier request overwrites newer results
async function search(query) {
const data = await fetchResults(query);
render(data); // whichever response lands last wins
}The lightweight fix is a request sequence number. Capture the current token before awaiting, then discard the response if a newer request has started in the meantime.
let latestRequestId = 0;
async function search(query) {
const requestId = ++latestRequestId;
const data = await fetchResults(query);
if (requestId !== latestRequestId) return; // superseded, drop it
render(data);
}That fixes the display, but the abandoned requests still consume bandwidth and server capacity. AbortController cancels them properly: call abort() on the previous controller before starting a new request, and pass the signal into fetch.
let controller = null;
async function search(query) {
controller?.abort(); // cancel the previous request
controller = new AbortController();
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
return await res.json();
} catch (error) {
if (error.name === 'AbortError') return null; // expected, ignore
throw error;
}
}An aborted fetch rejects with a DOMException whose name is AbortError. Always check for it explicitly. Reporting cancellations as failures is how error dashboards fill up with noise that nobody can act on.
The same signal mechanism gives you timeouts without hand-rolling a Promise.race. AbortSignal.timeout(ms) returns a signal that aborts itself, and AbortSignal.any combines several signals into one.
// Abort automatically after 5 seconds
const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
// Abort on timeout OR when the user cancels
const signal = AbortSignal.any([
AbortSignal.timeout(5_000),
userController.signal,
]);
const res = await fetch(url, { signal });Async Constructors and Top-Level await
Constructors cannot be async and cannot contain await. A constructor must return the instance synchronously, and an async function always returns a Promise, so the two are fundamentally incompatible.
Developers work around this by assigning a Promise to a field, which pushes the problem onto every caller: now every method has to remember to await this.ready first, and forgetting it is exactly the silent missing-await bug from Mistake 4.
// Invalid: SyntaxError, await is not allowed in a constructor
class Database {
constructor(url) {
this.connection = await connect(url);
}
}
// Fragile: callers must remember to await this.ready
class Database {
constructor(url) {
this.ready = connect(url); // easy to forget downstream
}
}The clean solution is a static async factory. The constructor stays synchronous and takes an already-resolved dependency, while a static method does the awaiting and hands back a fully initialised instance.
export class Database {
#connection;
// Private in spirit: callers should use Database.open()
constructor(connection) {
this.#connection = connection;
}
static async open(url) {
const connection = await connect(url);
return new Database(connection);
}
query(sql, params) {
return this.#connection.query(sql, params); // no readiness check needed
}
}
const db = await Database.open(process.env.DATABASE_URL);Every instance that exists is guaranteed usable, so no method needs a defensive readiness check. This is the pattern most database drivers and SDK clients converged on.
That final line uses top-level await, which works in ES modules ("type": "module" in package.json, or a .mjs file) and in TypeScript compiled to an ES2022 module target. It does not work in CommonJS, where the equivalent is an async IIFE or a lazily awaited singleton Promise.
// ES module: top-level await is allowed
const config = await loadConfig();
export default config;
// CommonJS: export a Promise and await it at the use site
const configPromise = loadConfig();
module.exports = { configPromise };Async initialisation also interacts with framework rendering models. In React, an async component boundary suspends and the scheduler decides when to resume, which is a different mechanism from the microtask queue described earlier. The React Fiber architecture explainer covers how that scheduling actually works.
If your async work involves timers, deadlines, or scheduling across time zones, prefer the modern date API over hand-rolled arithmetic on millisecond offsets. The Temporal API guide covers the replacement for Date in Node.js.
Async Code Review Checklist
Run through this list on any pull request that touches async code. Each item maps to a specific bug covered above, and most take seconds to verify.
- Every
awaiton an independent task has been checked againstPromise.all - No
asynccallback is passed toforEach,filter,some, orevery - Every
mapwith anasynccallback is wrapped inPromise.allorPromise.allSettled Promise.allover a variable-length array has a concurrency cap- Every
returninside atryblock that returns a Promise usesreturn await .catch()is placed after the last.then()it needs to cover- Promises are handed to a combinator immediately, not created and awaited one by one
@typescript-eslint/no-floating-promisesis enabled and passing- A
process.on('unhandledRejection')handler exists in every long-running service - Requests that can be superseded pass an
AbortSignalor carry a sequence token AbortErroris filtered out before errors are reported to monitoring- No class stores an unawaited Promise that callers must remember to await
Frequently Asked Questions
Does Promise.all actually run tasks in parallel?
- I/O-bound work (network requests, database queries): yes. Operations are initiated simultaneously and run concurrently at the network/OS level.
- CPU-bound computation: no. JavaScript is single-threaded, so
Promise.allcan't help here. - Most async bottlenecks in web apps are I/O, not CPU, so
Promise.allgives real speed gains in practice.
Why doesn't await inside forEach work?
forEachcalls each async callback and immediately discards the returned Promise.- It was designed before async/await existed and has no mechanism to wait for Promises.
- Execution continues synchronously after the
forEachcall, before any of the async work is done.
// forEach (fire-and-forget, broken)
items.forEach(async (item) => await fn(item));
// for...of (sequential, correct)
for (const item of items) { await fn(item); }
// Promise.all + map (parallel, correct)
await Promise.all(items.map(item => fn(item)));When should I use Promise.all vs Promise.allSettled?
| Promise.all | Promise.allSettled | |
|---|---|---|
| Rejects if any fails? | Yes (entire call rejects) | No (always resolves) |
| Use when | Every result is required | Partial success is acceptable |
| Best for | Critical data where missing = broken UI | Dashboards that show as much as they can |
How do I catch missing await bugs?
- `@typescript-eslint/no-floating-promises`: ESLint rule that flags async function calls without
await. See Step 4 for the config. - `--unhandled-rejections=strict`: Node.js flag that crashes the process on silent rejections during development.
- Tests: write tests that intentionally trigger the async path with failures to surface missing awaits.
When would I use Promise.race or Promise.any?
- `Promise.race`: resolves or rejects as soon as the first promise settles. Use for timeouts: race a request against a timer.
- `Promise.any`: resolves as soon as the first promise fulfills. Use for redundant requests where you want the fastest success.
- Both are advanced patterns for specific use cases, not general alternatives to
Promise.all.
Why does my async function return Promise { <pending> } instead of the value?
Because that is the only thing an async function can return. The return statement inside the body settles the Promise the function already handed back; it does not pass the value to the caller synchronously. When you log the call without awaiting it, the work has not finished yet, so you see the wrapper in its pending state.
async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
const user = getUser(1);
console.log(user); // Promise { <pending> }
const real = await getUser(1);
console.log(real.name); // 'Ada'If you cannot use await at that point (a non-async callback, or a CommonJS entry file), use .then() or wrap the work in an async IIFE. There is no synchronous escape hatch: the value genuinely does not exist yet at the moment the call returns, so no API can unwrap it for you.
How do I use async/await inside a React useEffect?
Never make the effect callback itself async. useEffect expects either nothing or a cleanup function back, and an async function returns a Promise, so React warns and your cleanup never runs. Declare an async function inside the effect and call it.
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
setUser(await res.json());
} catch (err) {
if (err.name !== 'AbortError') setError(err);
}
}
load();
return () => controller.abort();
}, [id]);The cleanup aborts the in-flight request whenever id changes or the component unmounts. That kills the classic bug where a slow response for an old id resolves last and overwrites the state belonging to the current one.
Filter AbortError out of your error handling. It is the expected outcome of a cancellation, not a failure, and showing it to the user or reporting it to monitoring is noise.
Most async performance bugs aren't complicated: they're await calls that should be Promise.all, and forEach loops that should be for...of.
Make a habit of asking one question before every await: does this task need the previous result? That question catches most of the mistakes in this post.
Related Articles
How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)
Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.
Drizzle ORM Migrations: A Practical drizzle-kit Guide
Learn the full Drizzle ORM migration workflow: push vs migrate, drizzle-kit setup, Turso/libSQL config, team conflicts, and production best practices.