, ,

Common Async Mistakes and How to Avoid Them

Posted by

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

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 happens
await 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 dependencyfor...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.allfail fast: if any reject, everything rejects. Best for “need all or bust.”
  • Promise.allSettled → gather all outcomes: perfect for dashboards, bulk jobs.
  • Promise.anyfirst success wins; rejects with AggregateError 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

Your email address will not be published. Required fields are marked *