A practical, copy-pastable guide to writing fast, safe, and predictable async JavaScript — without surprises.

Introduction
Async code is where JavaScript shines — and where bugs love to hide. From “why is this running in order A-D-C-B?” to “why did my server freeze?”, the same mistakes show up again and again across codebases.
This guide is your field manual. We’ll walk through the most common async mistakes (frontend + Node), why they happen, and exactly how to fix them — with tiny utilities, clear patterns, and real-world examples (React, fetch, Node I/O). Keep this tab open the next time an async bug wastes your afternoon.
1) Mistake: Thinking await
blocks the thread
What devs expect
console.log('A');
await doThing(); // “blocks, right?”
console.log('B');
What actually happensawait
yields the current async function. The function returns a Promise and continues later on a microtask when the awaited promise settles. The thread is free to do other work.
Why it hurts
- Logs happen “out of order.”
- UI jank when you rely on
await
to “pause” rendering. - Surprise reentrancy (event handlers firing in between).
Fix
Adopt the mental model: await
= “pause and resume later on a microtask.” If you must delay “just after this,” use a microtask explicitly:
await Promise.resolve(); // yield 1 tick before continuing
2) Mistake: Forgetting to return
inside .then
// ❌ WRONG: doB is unchained; the next then runs too early
doA()
.then(a => { doB(a); })
.then(useB);
Fix
// ✅ return the promise (or value)
doA()
.then(a => doB(a))
.then(useB)
.catch(handle);
Why.then
always returns a new promise whose resolution is whatever you return. If you forget to return, the next link sees undefined
and may run before the inner async work finishes.
3) Mistake: Using forEach
with async
callbacks
// ❌ .forEach ignores returned promises
items.forEach(async item => {
await save(item);
});
console.log('Done'); // prints before saves finish
Fix
Use for...of
for sequential work, or Promise.all
for parallel work.
// Sequential
for (const item of items) {
await save(item);
}
// Parallel
await Promise.all(items.map(save));
Rule of thumb
- Sequential dependency →
for...of
+await
- Independent tasks → kick them all off →
Promise.all
4) Mistake: Accidental serialization
// ❌ Unintended: runs one by one
const results = [];
for (const url of urls) {
results.push(await fetchJson(url));
}
Fix
Start first, await later:
const tasks = urls.map(fetchJson);
const results = await Promise.all(tasks);
This pattern is the performance upgrade most teams miss.
5) Mistake: Unhandled rejections
async function handler() {
doSideEffect(); // ❌ fire-and-forget; if it rejects, nobody hears it
}
Fix
async function handler() {
// Option A: await it
await doSideEffect();
// Option B: explicitly ignore but log
void doSideEffect().catch(reportError);
}
Why
A rejected promise without a .catch
bubbles as “Uncaught (in promise)” in browsers, and an unhandledRejection
in Node. Treat it like an exception: catch, log, and decide.
6) Mistake: Swallowing errors in .catch
doA()
.catch(e => console.warn('A failed', e))
.then(stepB); // ❌ stepB runs even if A failed
Fix
Either rethrow or return a safe fallback intentionally:
doA()
.catch(e => { console.warn(e); throw e; })
.then(stepB);
// or
doA()
.catch(e => {
console.warn(e);
return { fallback: true }; // explicit fallback value
})
.then(stepB);
Rule
Every .catch
should either rethrow or produce a substitute value. Silent swallow = spooky bugs.
7) Mistake: Trusting setTimeout(fn, 0)
to run “immediately”
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
// Output: A, D, C, B
Why
Promises go to the microtask queue; timers go to the macrotask queue. Microtasks run first.
Fix
When you need “after current stack but before timers,” use a microtask:
queueMicrotask(() => { /* runs before setTimeout(...) */ });
8) Mistake: Doing CPU-heavy work on the main thread
// ❌ Blocks UI / event loop
const end = Date.now() + 300;
while (Date.now() < end) {}
Fixes
- Chunking (UI friendly):
function chunked(items, n = 1000) {
let i = 0;
function next() {
const end = Math.min(i + n, items.length);
while (i < end) process(items[i++]);
if (i < items.length) setTimeout(next, 0); // yield
}
next();
}
- Offload to Web Workers (browser) or Worker Threads (Node) for real parallel CPU.
9) Mistake: Stale closures (React & timers)
function Counter() {
const [count, setCount] = useState(0);
function incLater() {
setTimeout(() => setCount(count + 1), 1000); // ❌ may use stale 'count'
}
}
Fix
Use functional updates that receive the fresh value:
setTimeout(() => setCount(c => c + 1), 1000);
Also watch for stale fetch callbacks in effects; track dependencies carefully or cancel on cleanup.
10) Mistake: No timeouts, no retries, no cancellation
Symptoms
- Hung spinners forever.
- Zombie network calls when the user navigates away.
- Flaky endpoints causing random failures.
Fixes (copy-paste)
Timeout wrapper
export const withTimeout = (p, ms, msg = 'Timeout') =>
new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(msg)), ms);
p.then(v => { clearTimeout(t); res(v); },
e => { clearTimeout(t); rej(e); });
});
Retry with backoff
export const retry = async (fn, { retries = 3, base = 200, factor = 2 } = {}) => {
let attempt = 0, last;
while (attempt++ <= retries) {
try { return await fn(); }
catch (e) {
last = e;
if (attempt > retries) break;
await new Promise(r => setTimeout(r, base * (factor ** (attempt - 1))));
}
}
throw last;
};
Cancellation (fetch)
const controller = new AbortController();
const p = fetch(url, { signal: controller.signal });
setTimeout(() => controller.abort(), 5000);
await p; // rejects with AbortError if aborted
11) Mistake: Flooding services (no concurrency limit)
// ❌ Slams 100 requests at once
await Promise.all(urls.map(fetchJson));
Fix: limit concurrency
export const limit = (fn, pool = 5) => {
let active = 0, queue = [];
const run = (...args) => new Promise((resolve, reject) => {
const task = async () => {
active++;
try { resolve(await fn(...args)); }
catch (e) { reject(e); }
finally {
active--;
if (queue.length) queue.shift()();
}
};
active < pool ? task() : queue.push(task);
});
return run;
};
// Usage
const limitedFetch = limit(fetchJson, 5);
const results = await Promise.all(urls.map(u => limitedFetch(u)));
Why
Protects your users and your APIs from rate-limiting, timeouts, and melted laptops.
12) Mistake: Mixing callbacks & promises incorrectly
// ❌ ignores errors; may double-resolve
new Promise(res => fs.readFile(path, (err, data) => res(data)));
Fix: correct promisify
export const promisify = (fn) => (...args) =>
new Promise((resolve, reject) =>
fn(...args, (err, val) => (err ? reject(err) : resolve(val)))
);
Use either callback style or promise style end-to-end — don’t half-wrap.
13) Mistake: Leaking resources (no cleanup on both outcomes)
const conn = await connect();
await query(conn); // throws?
await conn.close(); // ❌ never runs on error
Fix: finally
const conn = await connect();
try {
await query(conn);
} finally {
await conn.close();
}
Other leaks to watch
- Unremoved event listeners.
- Un-aborted fetches on unmount/navigate.
- Node streams not
.destroy()
ed on error.
14) Mistake: Using the wrong combiner (all
vs allSettled
vs any
vs race
)
Promise.all
→ fail fast: if any reject, everything rejects. Best for “need all or bust.”Promise.allSettled
→ gather all outcomes: perfect for dashboards, bulk jobs.Promise.any
→ first success wins; rejects withAggregateError
if all fail. Great for mirror endpoints/CDNs.Promise.race
→ first settled wins (even rejection). Great for timeouts:
await Promise.race([
fetch(url),
withTimeout(new Promise(() => {}), 3000) // rejects after 3s
]);
Choose deliberately.
15) Mistake: Losing context in errors
try {
await saveOrder(order);
} catch (e) {
throw new Error('save failed'); // ❌ loses original details
}
Better
try {
await saveOrder(order);
} catch (e) {
throw new Error(`save failed for order ${order.id}`, { cause: e });
}
And log with structure
catch (e) {
logger.error('save failed', { orderId: order.id, cause: e.message, stack: e.stack });
throw e;
}
Make errors actionable.
16) Mistake: Top-level await
surprises (modules)
Top-level await
pauses module evaluation. If two modules wait on each other, you can deadlock or slow startup.
Guidelines
- Use top-level
await
only for initialization that must block (loading config, WASM). - Otherwise, export an init function and let the app choose when to await.
- In SSR, beware blocking the render — kick off background initializers and hydrate later.
17) Mistake: Starving the event loop with microtasks
Creating huge chains of synchronously-resolving promises can starve timers/paint.
Smell
let p = Promise.resolve();
for (let i = 0; i < 1e6; i++) p = p.then(() => {});
// UI freezes; timers delayed forever
Fix
Occasionally yield to the loop:
if (i % 1000 === 0) await Promise.resolve(); // give microtask queue a breather
Or chunk with setTimeout
/requestIdleCallback
.
18) Mistake: Assuming this
behaves in async callbacks
const obj = {
name: 'Core',
greet() {
setTimeout(function() {
console.log(this.name); // ❌ undefined/global
}, 0);
}
};
Fix
Use an arrow (lexical this
) or bind:
setTimeout(() => console.log(this.name), 0);
// or
setTimeout(this.greet.bind(this), 0);
19) Testing Async Correctly (no flakes)
- Always return/await the promise in tests.
it('resolves', async () => {
await expect(doThing()).resolves.toBe(42);
});
- Use fake timers for retries/timeouts.
- Assert rejections explicitly:
await expect(withTimeout(delay(10), 5)).rejects.toThrow(/Timeout/);
- Flush microtasks if needed:
await Promise.resolve(); // one microtask turn
20) Patterns You’ll Reuse (cheat codes)
Parallel + limit + retry + timeout + cancel (all-in-one)
const limitedFetch = limit(
(url, signal) => withTimeout(fetch(url, { signal }), 5000).then(r => r.json()),
5
);
const controller = new AbortController();
try {
const data = await retry(
() => Promise.all(urls.map(u => limitedFetch(u, controller.signal))),
{ retries: 2, base: 300 }
);
render(data);
} catch (e) {
reportError(e);
} finally {
controller.abort(); // ensure stragglers are canceled
}
Quick Reference Table

Copy-Paste Utilities
export const withTimeout = (p, ms, msg = 'Timeout') =>
new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(msg)), ms);
p.then(v => { clearTimeout(t); res(v); },
e => { clearTimeout(t); rej(e); });
});
export const retry = async (fn, { retries = 3, base = 200, factor = 2 } = {}) => {
let attempt = 0, last;
while (attempt++ <= retries) {
try { return await fn(); }
catch (e) {
last = e;
if (attempt > retries) break;
await new Promise(r => setTimeout(r, base * (factor ** (attempt - 1))));
}
}
throw last;
};
export const limit = (fn, pool = 5) => {
let active = 0, queue = [];
const run = (...args) => new Promise((resolve, reject) => {
const task = async () => {
active++;
try { resolve(await fn(...args)); }
catch (e) { reject(e); }
finally {
active--;
if (queue.length) queue.shift()();
}
};
active < pool ? task() : queue.push(task);
});
return run;
};
export const promisify = (fn) => (...args) =>
new Promise((resolve, reject) =>
fn(...args, (err, val) => (err ? reject(err) : resolve(val)))
);
Conclusion
Async JavaScript is powerful — but predictable only when you lean on the right mental models and proven patterns:
await
yields; it doesn’t block.- Return in
.then
, rethrow or fallback in.catch
. - Parallelize with
Promise.all
, limit concurrency, and add timeouts/retries/cancellation. - Use functional updates to avoid stale closures.
- Clean up with
finally
and AbortController.
Pro tip: When debugging async, narrate it: “Where is this code — stack, microtask, or macrotask? What does this .then
return? Who catches the rejection? Am I starting work first or serializing accidentally?” That inner monologue exposes the bug fast.
Call to Action
Which async mistake have you seen most in your team — forEach
+ async
, unhandled rejections, or floods without a concurrency limit?
💬 Drop your war story (and fix) in the comments.
🔖 Bookmark this as your async checklist.
👩💻 Share with a teammate who just discovered Promise.any
.
Leave a Reply