d structured error boundaries. The following implementation demonstrates a production-grade pattern using TypeScript.
Step 1: Declare Async Functions with Explicit Return Types
Every function marked with async automatically wraps its return value in a resolved Promise. TypeScript enforces this by requiring Promise<T> return types.
interface TenantConfig {
id: string;
region: string;
tier: 'standard' | 'premium';
}
interface ProvisionResult {
status: 'success' | 'partial';
resourceIds: string[];
}
async function resolveTenantConfig(tenantId: string): Promise<TenantConfig> {
const response = await fetch(`/api/tenants/${tenantId}/config`);
if (!response.ok) throw new Error(`Config fetch failed: ${response.status}`);
return response.json();
}
Why this choice: Explicit return types prevent accidental Promise<void> returns and enable IDE autocompletion. The function remains non-blocking; the await yields control to the event loop until the fetch microtask resolves.
Step 2: Structure Sequential Dependencies with Clear Boundaries
When operations depend on previous results, sequential await is appropriate. Each step should validate its input before proceeding.
async function validateSubscription(config: TenantConfig): Promise<boolean> {
const validation = await fetch(`/api/subscriptions/${config.id}/status`);
const data = await validation.json();
return data.isActive === true;
}
async function provisionResources(config: TenantConfig): Promise<ProvisionResult> {
const db = await initializeDatabase(config.region);
const cache = await connectCacheLayer(config.tier);
return {
status: 'success',
resourceIds: [db.connectionId, cache.endpoint]
};
}
Why this choice: Separating validation and provisioning into distinct async functions creates testable units. Each function owns its error handling, preventing monolithic try/catch blocks from swallowing unrelated failures.
Step 3: Execute Independent Operations in Parallel
When operations don't depend on each other, sequential await creates unnecessary latency. Use Promise.all or Promise.allSettled to trigger concurrent microtasks.
async function initializeTenantEnvironment(tenantId: string): Promise<ProvisionResult> {
const config = await resolveTenantConfig(tenantId);
// Independent operations: run concurrently
const [isValid, provision] = await Promise.all([
validateSubscription(config),
provisionResources(config)
]);
if (!isValid) {
throw new Error('Subscription validation failed');
}
return provision;
}
Why this choice: Promise.all triggers both fetches immediately. The event loop schedules both microtasks concurrently. The await only pauses until the slowest promise settles. This reduces total latency from T1 + T2 to max(T1, T2).
Step 4: Implement Structured Error Boundaries
Wrap independent operation groups in separate try/catch blocks. This prevents a failure in one domain from masking errors in another.
async function executeTenantSetup(tenantId: string): Promise<void> {
try {
const result = await initializeTenantEnvironment(tenantId);
console.log(`Setup complete: ${result.resourceIds.join(', ')}`);
} catch (error) {
if (error instanceof Error) {
console.error(`Tenant setup failed: ${error.message}`);
// Trigger rollback or alerting logic here
}
} finally {
console.log('Cleanup phase initiated');
}
}
Why this choice: The try/catch covers only the orchestration layer. Domain-specific functions handle their own validation errors. The finally block guarantees cleanup execution regardless of success or failure, matching synchronous resource management patterns.
Pitfall Guide
1. The Blocking Fallacy
Explanation: Developers often assume await halts the entire JavaScript runtime. In reality, it only pauses the async function and yields control back to the event loop. Other tasks, timers, and microtasks continue executing.
Fix: Never wrap CPU-intensive synchronous code in await. Use await exclusively for Promise-returning operations. For heavy computation, offload to Web Workers or child processes.
2. The Monolithic Try/Catch Trap
Explanation: Wrapping multiple independent operations in a single try/catch block makes it impossible to determine which step failed. Stack traces become ambiguous, and error recovery logic grows complex.
Fix: Isolate error boundaries per domain. Use separate try/catch blocks for independent operations, or leverage Promise.allSettled to capture individual rejection reasons without failing the entire batch.
3. Sequential Bottlenecks on Independent Tasks
Explanation: Writing await taskA(); await taskB(); when A and B don't depend on each other forces serial execution. This multiplies latency unnecessarily.
Fix: Identify dependency graphs. Use Promise.all() for concurrent execution. Reserve sequential await strictly for operations where step N requires the output of step N-1.
4. Implicit Promise Return Mismatches
Explanation: Forgetting that async functions always return Promises leads to type errors when assigning results to non-Promise variables or passing them to synchronous APIs.
Fix: Always declare explicit Promise<T> return types in TypeScript. When consuming async functions, ensure the caller either uses await or handles the Promise directly.
5. Unhandled Rejections in Promise.all
Explanation: Promise.all fails fast. If one promise rejects, the entire batch rejects immediately, potentially leaving other pending promises unresolved and causing unhandled rejection warnings.
Fix: Use Promise.allSettled() when you need all results regardless of individual failures. Map the settled results to extract status and values, then handle failures explicitly.
6. Top-Level Await Context Errors
Explanation: Using await outside an async function throws a syntax error in CommonJS or browser scripts. Top-level await only works in ES modules or dynamic import contexts.
Fix: Wrap top-level async logic in an immediately invoked async function expression (IIAFE), or ensure your build target and module system support ES modules with top-level await.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Dependent API calls (B needs A's result) | Sequential await | Data dependency requires linear execution | Low latency, high correctness |
| Independent data fetches (user profile + settings) | Promise.all() | Concurrent microtask scheduling reduces total wait time | Lower latency, higher memory overhead |
| Batch processing with partial failure tolerance | Promise.allSettled() | Captures all results without failing on first rejection | Higher processing cost, better resilience |
| Race condition resolution (fastest response wins) | Promise.race() | Returns first settled promise, cancels others | Unpredictable resource usage, requires cleanup |
| External service with unpredictable latency | Async wrapper with timeout + retry | Prevents indefinite hanging and handles transient failures | Slightly higher complexity, improved reliability |
Configuration Template
// async-utils.ts
export interface AsyncOptions {
timeoutMs?: number;
retries?: number;
retryDelayMs?: number;
}
export async function withTimeout<T>(
promise: Promise<T>,
ms: number
): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: AsyncOptions = {}
): Promise<T> {
const { retries = 3, retryDelayMs = 1000 } = options;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === retries) throw error;
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
}
}
throw new Error('Retry logic exhausted');
}
// Usage example
export async function fetchSecureResource(url: string): Promise<unknown> {
return withRetry(
() => withTimeout(fetch(url).then((r) => r.json()), 5000),
{ retries: 3, retryDelayMs: 1500 }
);
}
Quick Start Guide
- Initialize a TypeScript project with
tsc --init and set "module": "ESNext" and "target": "ES2017" in tsconfig.json to ensure native async/await support.
- Create an async function with explicit return types and a single
await expression. Verify it returns a Promise by logging the result type.
- Add a try/catch block around the await. Trigger a rejection by calling a non-existent endpoint or throwing manually. Confirm execution jumps to catch without crashing the process.
- Introduce a second independent operation and wrap both in
Promise.all(). Measure execution time before and after to validate concurrent scheduling.
- Run the script with
node --experimental-specifier-resolution=node dist/index.js (or use ts-node/tsx for direct execution). Observe the event loop yield behavior in console logs.