Promises and Async/Await: Modern Async Patterns
Deep dive into promises, async/await, and patterns for managing asynchronous operations in production Node.js services.
Table of Contents
- Promise Fundamentals
- Promise States and Transitions
- Promise Chaining and Composition
- Error Handling Patterns
- Async/Await Mechanics
- Concurrency Patterns
- Promise Anti-Patterns
- Performance Considerations
- Memory Management
- Production Patterns
Promise Fundamentals
The Promise Contract
A promise represents a value that may not be available yet but will be resolved in the future. It's a proxy for an async operation's eventual result or failure.
Promises formalize the callback pattern with a standard interface. Every promise follows the Promises/A+ specification, ensuring interoperability between libraries and frameworks.
The key insight: promises separate async operation initiation from result handling. You can pass promises around, compose them, and attach handlers at any time.
Constructor Pattern
Promises are created with a constructor that receives an executor function. The executor receives resolve and reject functions to signal completion.
The executor runs synchronously when the promise is constructed. Async work should be initiated in the executor, with resolve/reject called when work completes.
Common mistake: forgetting that the executor runs immediately. Side effects in executors happen when the promise is created, not when then() is called.
Microtask Scheduling
Promise callbacks always execute as microtasks, even when the promise is already settled. This ensures consistent async behavior regardless of timing.
When you call then() on a settled promise, the callback doesn't execute immediately—it's scheduled as a microtask. This prevents surprising synchronous execution.
Understanding microtask timing is critical for reasoning about execution order when mixing promises with other async primitives.
Promise vs Callback
Promises provide several advantages over callbacks:
- Standard error handling mechanism
- Composability through chaining
- Avoidance of callback hell
- Better stack traces with async/await
However, promises have overhead. For extremely hot code paths, callbacks may be more performant. Measure before optimizing.
Promise States and Transitions
The Three States
Promises exist in exactly one state:
- Pending: Initial state, not yet fulfilled or rejected
- Fulfilled: Operation completed successfully
- Rejected: Operation failed with an error
State Transitions
Once a promise transitions from pending to fulfilled or rejected, it becomes settled and never changes state again. This immutability is fundamental to promise semantics.
You cannot "cancel" a promise—once created, it will eventually settle. The operation may still be running, but the promise itself cannot return to pending.
This has implications for resource cleanup. If you start an operation and no longer need the result, the operation continues unless explicitly cancelled through a separate mechanism.
Settlement Timing
A promise can settle synchronously (during constructor execution) or asynchronously. But callbacks attached via then() always execute asynchronously, as microtasks.
This consistency allows reasoning about code without knowing whether promises settle synchronously or asynchronously. Handlers always execute "later," never immediately.
Value and Reason
Fulfilled promises have a value—the successful result. Rejected promises have a reason—typically an error object explaining the failure.
Values and reasons can be any JavaScript type. Convention is to use Error objects for rejections, but this isn't enforced. Always check rejection reason types in catch handlers.
Promise Chaining and Composition
Chaining Mechanism
Every then() call returns a new promise. This enables chaining: the next then() receives the previous then's return value.
If a then() callback returns a value, the next then() receives that value. If a then() callback returns a promise, the next then() waits for that promise to settle.
This flattening behavior prevents nested promise chains. You can return promises from then() callbacks without creating deeply nested structures.
Transformation Chains
Promise chains enable transformation pipelines. Each stage transforms the previous stage's output:
Fetch data, parse it, validate it, transform it, save it. Each stage is a then() callback that returns the transformed value or a promise for async transformation.
Errors in any stage skip remaining fulfillment handlers and jump to the next catch() handler. This mimics synchronous try/catch behavior.
Parallel Composition
Promise.all() executes promises concurrently and resolves when all settle successfully. If any reject, Promise.all() immediately rejects.
This is useful for independent operations that should execute concurrently. But note: all promises start immediately when created. Promise.all() doesn't control execution, only result collection.
Promise.allSettled() waits for all promises regardless of success/failure. Results include status (fulfilled/rejected) and value/reason. Use when you need all results even if some operations fail.
Promise.race() settles with the first promise to settle, ignoring others. Useful for timeout patterns—race a real operation against a timeout promise.
Promise.any() fulfills when any promise fulfills, rejecting only if all reject. Less commonly used but valuable for fallback scenarios.
Sequential Composition
Chaining provides sequential composition—each operation starts after the previous completes. For arrays of operations, use reduce() to build a chain dynamically.
Alternatively, use async/await with loops for more readable sequential processing.
Error Handling Patterns
Rejection Propagation
Rejections propagate through chains automatically. If a then() callback throws or returns a rejected promise, subsequent then() callbacks are skipped until a catch() handler.
This creates automatic error bubbling similar to synchronous try/catch blocks. Errors don't need explicit forwarding.
Catch Placement
Where you place catch() determines error handling scope. A catch() at the end handles all errors from the entire chain. A catch() mid-chain handles errors from previous stages but allows the chain to continue.
Returning a value from a catch() handler converts rejection to fulfillment. The chain continues with the returned value. This enables error recovery—catch the error, apply a fallback, and continue.
Finally Handlers
The finally() method runs regardless of fulfillment or rejection. It's useful for cleanup operations that should always execute.
Finally handlers don't receive the value or reason—they're for side effects like closing connections or releasing resources. The original settled value passes through finally handlers.
Unhandled Rejections
Promises without catch() handlers generate unhandled rejection warnings. In future Node.js versions, unhandled rejections will crash the process.
Always attach error handlers to promises. Even if you can't recover, log the error. Silent failures mask problems until they cause critical issues.
Process-level unhandledRejection event listeners can catch missed rejections, but shouldn't replace proper error handling. Use them for last-resort logging and monitoring.
Error Classification
Distinguish between operational errors (network failures, validation errors) and programmer errors (bugs, logic errors).
Operational errors should be handled explicitly—retry, fallback, user notification. Include context in error objects to aid debugging.
Programmer errors indicate bugs. Let them propagate to crash handlers and fix the underlying issue. Don't try to recover from bugs programmatically.
Async/Await Mechanics
Syntactic Sugar
Async/await is syntactic sugar over promises. An async function returns a promise. Await suspends execution until a promise settles.
Under the hood, the JavaScript engine transforms async functions into promise chains. The cleaner syntax doesn't change the underlying async mechanism.
Execution Model
When an async function hits await, it suspends and returns a pending promise to its caller. The event loop continues processing other work.
When the awaited promise settles, the async function resumes from the await point with the fulfilled value. If rejected, await throws the rejection reason.
This cooperative multitasking model maintains single-threaded execution while enabling concurrent I/O operations.
Stack Traces
Async/await improves stack traces compared to promise chains. When an error occurs, the stack trace includes the async function call chain, not just the immediate callback.
This makes debugging significantly easier. You can see how execution reached the error point across async boundaries.
However, stack traces still don't cross event loop iterations. Errors lose context from previous iterations. Use error tracking services to reconstruct full async context.
Top-Level Await
Modern Node.js supports top-level await in modules. This allows await at module scope, outside async functions.
Top-level await blocks module evaluation until awaited promises settle. This affects application startup time and module dependency loading order.
Use sparingly—excessive top-level awaits slow application startup. Prefer lazy loading expensive resources after application initialization.
Error Handling
Try/catch works naturally with async/await. Wrap await calls in try/catch to handle rejections as thrown errors.
This unifies synchronous and asynchronous error handling. The same try/catch block can handle both types of errors.
Unhandled errors in async functions become unhandled rejections. Always use try/catch or attach catch() to async function calls.
Concurrency Patterns
Promise.all for Parallel Operations
When operations are independent, execute them concurrently with Promise.all(). This maximizes throughput by overlapping I/O wait time.
Create all promises first, then await Promise.all(). Creating promises sequentially defeats the purpose—operations start immediately when promises are created.
Limit concurrency for resource-intensive operations. Don't create unbounded concurrent operations. Use pooling or batching to control resource usage.
Sequential Execution
Use for-of loops with await for sequential processing. Each iteration waits for the previous to complete before starting.
Sequential execution is necessary when operations depend on previous results or when you need to limit load on downstream services.
Concurrent Batching
Process large arrays in concurrent batches. Create chunks of work, process each chunk with Promise.all(), then move to the next chunk.
This balances throughput (concurrent operations within chunks) with resource limits (bounded concurrency per chunk).
Libraries like p-limit provide utilities for concurrent operation limiting. They maintain a running pool of concurrent operations up to a specified limit.
Timeout Patterns
Implement timeouts using Promise.race() with a timeout promise. Race the real operation against a promise that rejects after a delay.
Always clean up timed-out operations. Timeouts don't cancel the underlying operation—they only stop waiting for results. Implement explicit cancellation if resources must be released.
Retry Patterns
Implement retries with exponential backoff. After failure, wait progressively longer between retries: 1s, 2s, 4s, 8s.
Add jitter—randomize delays to prevent thundering herd when multiple clients retry simultaneously.
Limit retry count to prevent infinite loops. Use circuit breakers to stop retries when a service is definitively down.
Cancellation
Promises don't have native cancellation. Use AbortController for explicit cancellation support.
Pass an AbortSignal to operations that support cancellation. When you call abort(), the signal fires an abort event that operations can listen for.
This pattern is common in fetch API and is spreading to other Node.js APIs.
Promise Anti-Patterns
The Explicit Construction Anti-Pattern
Don't wrap functions that return promises in new Promise(). This creates unnecessary promise wrappers and complicates error handling.
If a function returns a promise, use it directly. Only use new Promise() to wrap callback-based APIs.
The Forgotten Return
Forgetting to return promises from then() callbacks breaks chains. The next then() receives undefined instead of the promise result.
Always return values or promises from then() callbacks unless you're performing side effects without needing the result.
Nested Promise Chains
Don't nest then() calls inside other then() callbacks. This recreates callback hell with promises.
Flatten chains by returning promises from then() callbacks. The promise machinery handles nesting automatically.
Catching Without Rethrowing
Catching errors mid-chain without rethrowing converts rejections to fulfillments. Subsequent catch() handlers won't fire.
If you catch for logging but can't recover, rethrow the error or return a rejected promise to continue error propagation.
Async Function Pitfalls
Forgetting await makes async functions return pending promises instead of values. The function completes before async work finishes.
Linters can detect missing await in many cases, but not all. Be vigilant when calling async functions.
Sequential Awaits
Awaiting promises sequentially when they could run concurrently wastes time. Each await pauses execution until the promise settles.
Create all promises first, then await Promise.all() to execute concurrently.
Performance Considerations
Promise Creation Overhead
Each promise allocates memory and creates microtask callbacks. For extremely hot code paths, this overhead may be noticeable.
Benchmark before optimizing. Promise overhead is small compared to actual I/O operations in most cases.
Consider callback-based APIs for truly performance-critical code, but prioritize code clarity and maintainability.
Microtask Queue Pressure
Heavy promise usage creates many microtasks. The microtask queue is processed between operations, potentially delaying event loop progression.
This is rarely a problem in practice unless you're creating thousands of promises per event loop iteration. Monitor event loop lag to detect microtask pressure.
V8 Optimization
V8 optimizes promise code through inlining and escape analysis. Modern V8 versions have made promises nearly as fast as callbacks for many workloads.
Async/await is particularly well-optimized. The syntactic transformation allows more optimization than manual promise chains.
Memory Allocation
Long promise chains retain memory for the chain structure. Each then() creates a new promise and callback references.
For long-running operations with extensive chains, this memory accumulates. Break long chains into separate functions to allow garbage collection of intermediate promises.
Memory Management
Promise Retention
Promises retain references to their callbacks until settled. Callbacks capture their closure scope, preventing garbage collection of referenced variables.
This creates memory leaks if promises never settle. Always ensure promises eventually resolve or reject.
Closure Scope
Be mindful of closure scope in promise callbacks. Capturing large objects in closures prevents garbage collection.
Extract minimal required data from large objects before creating promise callbacks. Let the large objects be garbage collected.
Cancel Pattern
For operations that may be abandoned, implement explicit cancellation to release resources. Don't rely on promise rejection for cleanup.
Use WeakMaps to associate cleanup logic with promises. When promises are garbage collected, cleanup can occur.
Production Patterns
Standardized Error Objects
Create application-specific error classes for different failure modes. Include context properties for debugging.
Enrich errors with operation parameters, timestamps, and correlation IDs. This aids troubleshooting in production.
Operation Tracking
Assign unique IDs to async operations. Log operation start, completion, and errors with the operation ID.
This enables tracing async operation flows through logs. Distributed tracing systems use this pattern across service boundaries.
Timeout Enforcement
Never rely on external services to return quickly. Always enforce timeouts for network operations, database queries, and external API calls.
Implement timeouts at multiple layers—connection timeout, request timeout, and overall operation timeout.
Circuit Breakers
Track failure rates for external dependencies. When failure rate exceeds threshold, open circuit breaker to fail fast instead of waiting for timeouts.
Circuit breakers prevent cascading failures and reduce resource consumption when dependencies fail.
Graceful Degradation
Design operations to degrade gracefully when dependencies fail. Return cached data, default values, or reduced functionality instead of complete failure.
Distinguish between critical operations that must succeed and optional operations that can fail without breaking core functionality.
Monitoring and Alerting
Instrument promise-based operations extensively:
- Duration histograms (p50, p95, p99)
- Error rates by error type
- Timeout rates
- Retry rates and success
- Circuit breaker state changes
Alert on degrading performance or elevated error rates before they impact users.
Structured Logging
Log operation context with structured fields. Include operation type, parameters, duration, and outcome.
Structure enables log aggregation and analysis. Query logs to identify patterns and troubleshoot issues.
Health Checks
Implement health check endpoints that verify critical dependencies. Use promise-based checks with timeouts.
Health checks enable load balancers and orchestration systems to detect unhealthy instances and route traffic appropriately.