NashTech Blog

5 Common Mistakes in Asynchronous Programming (and How to Avoid Them)

Table of Contents
A focused female software engineer coding on dual monitors in a modern office.

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.forEach doesn’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.all fires 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 undefined on 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.

Leave a Comment

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

Suggested Article

Scroll to Top