JavaScript Async/Await Patterns That Trip Up Senior Devs

JavaScript Async/Await Patterns That Trip Up Senior Devs
Brandon Perfetti

Technical PM + Software Engineer

Topics:JavaScriptAsync/AwaitPromises
Tech:Node.jsBrowsers

Confession: I shipped code that looked async-correct but silently failed in production because I treated async/await like syntactic sugar over synchronous code. The bugs were subtle: background jobs that never finished, API requests that raced and leaked memory, and error handling that caught nothing. As a product-minded engineer I learned the hard way that async patterns are a behavioral contract between your intent, runtime, and reviewers. This article lists the specific async/await patterns that still trip up senior developers, explains why they bite, and gives concrete, implementable fixes you can adopt today.

1) The 'I forgot to await' and floating promise trap

Symptom: you call an async function inside a handler or loop and forget to await it. The code compiles and tests may pass, yet runtime behavior differs because the promise is never observed. Common locations are event handlers, array iteration functions, and returns from functions that are expected to yield work completion.

Why it hurts: floating promises either run detached from control flow or become unhandled rejections. The former causes lost work and race conditions; the latter produces noisy logs or crashes under Node's unhandled rejection policy.

  • Avoid array.forEach with async callbacks. Replace with for...of and await to get sequential behavior, or map to create promises and await Promise.all for parallel.
  • If you intend detached background work, explicitly document and wrap it: callDetached(asyncFn()).catch(report) or use a helper spawn(fn) that logs failures so the intent is visible.
  • Enable a linter rule that flags unused promises, e.g., TypeScript no-floating-promises via eslint-plugin-floating-promises or eslint-plugin-promise.
  • In exported API-level functions, always return or await inner promises so callers get the right lifecycle.

2) Promise.all vs Promise.allSettled: pick the right failure semantics

Symptom: you use Promise.all expecting it to give you everything or partial results, but a single failure tosses away everything. Conversely, you use allSettled and then ignore rejected results.

Why it hurts: Promise.all expresses an all-or-nothing contract — if any promise rejects, the whole aggregate rejects. allSettled never rejects and offloads failure handling to you. Misusing either leads to lost errors or brittle retries.

  • Use Promise.all when the operation must succeed atomically: if any dependent call failing should fail the whole operation, all is correct.
  • Use Promise.allSettled when you can tolerate partial success and need per-item diagnostics — then immediately filter results and handle rejections explicitly.
  • Pattern: const jobs = items.map(item => call(item)); const results = await Promise.allSettled(jobs); const successes = results.filter(r => r.status === 'fulfilled').map(r => r.value); const errors = results.filter(r => r.status === 'rejected');
  • If you need best-effort but want the first failure to stop further processing, implement custom control flow: start all but abort in-flight with AbortController when any early failure occurs.

3) Sequential vs parallel — pick and control concurrency

Symptom: you either run things serially when you could parallelize (slow) or run everything at once and consume too much memory or hit rate limits (unstable). Senior devs often default to simplicity and stick to sequential patterns or naively use Promise.all without thought.

Why it hurts: network latency multiplies if serialized; unbounded concurrency leads to connection pool exhaustion, OOMs, or third-party rate limit penalties.

  • If order matters, use for...of and await for sequential execution. Don't overcomplicate with map+Promise.all; readability is a feature.
  • If order doesn't matter, collect promises and use Promise.all to parallelize: const promises = items.map(doWork); await Promise.all(promises).
  • If you must limit concurrency, use a small utility: p-limit or write a simple batching helper. Example: process items in chunks of N with Promise.all on each chunk.
  • Prefer streaming pipelines for very large datasets: use async iterators and process with a bounded concurrency pool instead of loading all promises into memory.

4) Error handling gotchas and try/catch scope

Symptom: errors escape try/catch blocks because you forgot to await inside the try, or you attached .catch to promises which transforms rejections into resolved values that silence Promise.all rejection, hiding root causes.

Why it hurts: misplaced try/catch gives a false sense of safety. Swallowing errors in individual promises prevents higher-level aggregations from seeing failures and blocks retries or alerts.

  • Wrap await calls in try/catch, not the non-awaited promise creator. Wrong: try { fetch(); } catch {} — fetch returns a promise, so the catch never fires. Right: try { await fetch(); } catch (e) { handle(e); }
  • If using Promise.all and you want to report individual errors while failing the aggregate, transform errors into annotated rejections rather than absorbing them: const wrapped = p.then(v => ({ok:true, v}), e => Promise.reject({ok:false, e, meta}));
  • Avoid appending .catch to promises you later pass to Promise.all unless you re-throw; catching there converts rejects into fulfills and changes semantics.
  • Use library-level error enrichment (contexts, request IDs) and ensure caught errors are re-thrown or logged with enough context.

5) Resource considerations: cancellation, timeouts, and backpressure

Symptom: long-running tasks leak resources or continue after the user navigates away. Massive concurrent jobs kill apps. Tests pass because the environment differs from production.

Why it hurts: without cancellation and timeouts, you can't implement backpressure or fair resource sharing. Services become unreliable under load and hard to reason about in failure modes.

  • Adopt AbortController/AbortSignal for cancellable fetch and custom async operations. Pass and check the signal in any function that does IO or long work.
  • Wrap external calls with a timeout wrapper that rejects after a sensible threshold so hung requests don't block Promise.all forever.
  • Implement rate limits or concurrency caps for third-party APIs. Use p-limit or a semaphore pattern to enforce a max concurrent count.
  • Instrument and monitor in-flight promises: expose metrics for concurrency, average latency, and rejection rate so you can detect explosion early.

6) Catching async mistakes in code review, tests, and CI

Symptom: code reviews miss async bugs because reviewers focus on business logic, not subtle behavioral differences between concurrency strategies.

Why it hurts: the team cycles through the same mistakes without systemic change. The fix is tooling plus explicit review checklist items.

  • Add async-specific PR checklist items: Are promises awaited? Is concurrency bounded? Will failures be visible and actionable?
  • Enable linters that detect common issues: no-floating-promises, consistent-return for async, and banning forEach for async callbacks where necessary.
  • Write tests that simulate concurrency and failure modes: stub network calls to reject intermittently and assert aggregator behavior for Promise.all vs allSettled.
  • Create integration tests that run against a local or sandboxed service under load to find OOMs and rate-limit responses.
  • Use observability in CI: run smoke tests that assert no unhandled rejections and that background jobs complete within timeouts.

Conclusion

Async/await is deceptively simple until it isn't. The mistakes seasoned devs make are predictable: forgetting to await, mischoosing between Promise.all and allSettled, running unbounded concurrency, and misplacing try/catch blocks. The fix is not just individual vigilance — it is a blend of clear patterns, small helpers, lint rules, tests that exercise failure modes, and runtime safeguards like AbortController and timeouts. As a pragmatic engineer, prefer explicit over implicit: return promises, document detached work, cap concurrency, and make failures visible. Those habits will stop most of the bugs that sneak through code review.

Action Checklist

  1. Add a lint rule for floating promises and configure CI to fail on violations.
  2. Replace array.forEach async callbacks with for...of or map+Promise.all with clear intent.
  3. Audit key places using Promise.all and decide whether all-or-nothing or partial success is the right semantic; implement allSettled handling where needed.
  4. Introduce a p-limit or batch helper in your codebase and refactor hotspots to enforce bounded concurrency.
  5. Create a small test suite that intentionally injects failures and asserts your aggregator behavior; include unhandled rejection detection in CI.