
Async code makes apps responsive – but it also creates sneaky bugs and performance issues. Below are five mistakes I see most often in JavaScript/TypeScript, with broken code, the fix, and why the fix is better.
1. await in the wrong kind of loop (or serial work that could be parallel)
The mistake (JS/TS): Using forEach/map with await incorrectly, or doing requests one-by-one when they could run in parallel.
const ids = [1, 2, 3, 4];
ids.forEach(async (id) => {
const user = await api.getUser(id); // not awaited by forEach
console.log(user.name);
});
What goes wrong?
Array.forEachdoesn’t await the inner async function → the outer function returns before work finishes.
- Even with a
for..of+await, you may run requests sequentially when they could run faster in parallel.
Here is how to fix
const users = await Promise.all(ids.map((id) => api.getUser(id)));
users.forEach(u => console.log(u.name));
Why this is optimal?
Promise.allfires requests together, reducing total latency.
- You still get a single await point for error handling.
2. Swallowing errors (or letting them explode as unhandled rejections)
The mistake (JS/TS): Relying on implicit promise errors, or using catch but not propagating context.
async function loadProfile(id: number) {
try {
return await api.getProfile(id);
} catch (e) {
console.error('Failed'); // swallowed
}
}
What goes wrong?
- Callers can’t react (retry, fallback, show message) because the function resolves to
undefinedon failure.
- Unhandled rejections crash processes or trigger noisy logs later.
To fix this let’s return a Result or rethrow with context:
Option 1: Rethrow with context
async function loadProfile(id: number) {
try {
return await api.getProfile(id);
} catch (e) {
throw new Error(`loadProfile(${id}) failed: ${(e as Error).message}`);
}
}
Option 2: Explicit Result type
type Result<T> = { ok: true; data: T } | { ok: false; error: Error };
async function loadProfileSafe(id: number): Promise<Result<Profile>> {
try {
const data = await api.getProfile(id);
return { ok: true, data };
} catch (e) {
return { ok: false, error: e as Error };
}
}
This is optimal because the callers can reliably handle failures, and you preserve context—crucial for debugging production issues.
3. Ignoring cancellation and timeouts
The mistake: Starting async work but never cancelling it (user navigates away, request becomes stale), or allowing calls to hang indefinitely.
What goes wrong?
- Wasted bandwidth and battery.
- Stale responses race back and overwrite fresh UI.
- Threads/dispatchers remain busy → UI jank.
To fix this use AbortController and timeouts:
function fetchWithTimeout(url: string, ms = 8000, init?: RequestInit) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), ms);
return fetch(url, { ...init, signal: controller.signal })
.finally(() => clearTimeout(id));
}
try {
const res = await fetchWithTimeout('/api/user', 8000);
const data = await res.json();
} catch (e) {
if ((e as Error).name === 'AbortError') {
// request cancelled or timed out
}
}
This is optimal because resources are released quickly, users don’t see outdated data or spinning loaders forever, and plays nicely with structured concurrency (children cancel with parents).
4. Blocking the main thread / event loop with CPU-heavy work
The mistake (Node/JS): Doing heavy CPU work (compression, encryption, parsing huge JSON) on the main event loop.
app.post('/signup', (req, res) => {
const hash = bcrypt.hashSync(req.body.password, 12); // sync & heavy
saveUser({ ...req.body, hash });
res.sendStatus(201);
});
What goes wrong?
- Other requests stall → poor tail latency.
- UI thread (in React Native / web) stutters.
To fix this use async libraries or offload to workers
app.post('/signup', async (req, res) => {
const hash = await bcrypt.hash(req.body.password, 12);
await saveUser({ ...req.body, hash });
res.sendStatus(201);
});
For really heavy work, use Worker Threads:
// worker.ts
parentPort!.on('message', (pwd: string) => {
const hash = bcrypt.hashSync(pwd, 12);
parentPort!.postMessage(hash);
});
// main.ts
const worker = new Worker('./worker.js');
const hash = await new Promise<string>(resolve => {
worker.once('message', resolve);
worker.postMessage(req.body.password);
});
5. Race conditions and out-of-order results
The mistake: Allowing slow responses to overwrite newer state (classic in search boxes / “typeahead”), or mutating shared state without guards.
let currentQuery = '';
async function search(q: string) {
currentQuery = q;
const data = await api.search(q);
render(data); // might render stale result
}
To fix this tag each request and ignore stale responses:
let lastIssued = 0;
async function search(q: string) {
const token = ++lastIssued;
const data = await api.search(q);
if (token === lastIssued) {
render(data); // only render if this is the latest call
}
}
Or you can cancel stale requests with AbortController:
let controller: AbortController | null = null;
async function search(q: string) {
controller?.abort();
controller = new AbortController();
const res = await fetch(`/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal
});
render(await res.json());
}
This is optimal because it guarantees UI consistency, prevents “stale overwrite” bugs that are hard to reproduce, and cancellation saves bandwidth & CPU.
Extra polish: timeouts, retries, backoff, and circuit breakers
Combine the above with resilience patterns:
JS/TS retry with exponential backoff
async function retry<T>(op: () => Promise<T>, max = 3): Promise<T> {
let delay = 200;
let last: unknown;
for (let i = 0; i < max; i++) {
try {
return await op();
} catch (e) {
last = e;
}
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2;
}
throw last;
}
Conclusion
Great async code is not just about “using await”—it’s about structured concurrency, cancellation, and correctness under load. Apply these patterns and you’ll see faster screens, fewer flakes, and logs that actually help you debug.