JavaScript Async/Await Patterns That Trip Up Senior Devs

Technical PM + Software Engineer
Async/await made JavaScript more readable, but it also made subtle failure modes easier to hide.
Most production incidents in async code are not syntax problems. They are policy problems:
- what is required vs optional,
- what should run in parallel,
- what can be retried safely,
- what must be cancelled,
- and what errors must retain context.
This article focuses on production-grade patterns that prevent the failures senior teams still run into.
Pattern 1: Separate required and optional concurrency
Promise.all is correct only when all results are required.
If partial data is acceptable, use Promise.allSettled and encode policy explicitly.
const [profileR, activityR, recsR] = await Promise.allSettled([
fetchProfile(userId),
fetchActivity(userId),
fetchRecommendations(userId),
]);
if (profileR.status !== "fulfilled") throw new Error("profile unavailable");
if (activityR.status !== "fulfilled") throw new Error("activity unavailable");
const recommendations = recsR.status === "fulfilled" ? recsR.value : [];
In plain English: make degradation policy visible in code, not implied by accident.
Pattern 2: Avoid accidental serialization
Sequential await on independent calls adds avoidable latency.
const flagsP = getFlags(userId);
const permsP = getPermissions(userId);
const prefsP = getPreferences(userId);
const [flags, perms, prefs] = await Promise.all([flagsP, permsP, prefsP]);
Rule: start independent work first, then await together.
Pattern 3: Never use async forEach
forEach does not await callback promises.
Use:
for...offor ordered/limited work,- bounded-concurrency worker pools for bulk tasks.
for (const item of items) {
await processItem(item);
}
Or bounded parallelism with explicit limits when throughput matters.
Pattern 4: Propagate cancellation
Async work should stop when callers no longer need results.
Use AbortController and pass AbortSignal through transport boundaries.
async function fetchBundle(id: string, signal: AbortSignal) {
const [a, b] = await Promise.all([
fetch(`/api/a/${id}`, { signal }).then(r => r.json()),
fetch(`/api/b/${id}`, { signal }).then(r => r.json()),
]);
return { a, b };
}
Without cancellation, you pay for stale work and risk writing outdated results.
Pattern 5: Preserve error context
Do not replace rich failures with generic errors.
Bad:
catch {
throw new Error("request failed");
}
Better:
catch (err) {
throw new Error("request failed", { cause: err as Error });
}
Add stable error codes at domain boundaries for consistent handling.
Pattern 6: Retry only retryable work
Retries without policy create duplicate side effects.
Before retrying, answer:
- Is operation idempotent?
- Is failure class retryable?
- Is backoff/jitter applied?
Use idempotency keys for write operations with external effects.
Pattern 7: Keep transport and domain concerns separate
Layer async code:
- transport: timeout/retry/cancellation,
- orchestration: sequencing/parallelism,
- domain: business decisions and user-facing behavior.
Mixing these layers makes code hard to test and easy to break.
Pattern 8: Define async contracts explicitly
For high-impact async functions, document:
- thrown error families,
- retryability,
- idempotency,
- cancellation support.
If callers guess these rules, behavior will drift.
Sanity checks for async code
Before you ship:
- Independent work is parallelized intentionally.
- Required vs optional outcomes are explicit.
- Cancellation is propagated where applicable.
- No async
forEach. - Errors preserve causal context.
- Retry policy is idempotency-aware.
- Contract assumptions are documented.
Closing
Async/await is not inherently risky. Implicit control-flow policy is.
When concurrency, cancellation, degradation, and retries are explicit, async systems become easier to reason about and significantly more reliable under production pressure.