Promises are fundamental to modern JavaScript, but mastering asynchronous control flow requires going beyond basic .then() chains. This article explores advanced Promise patterns that intermediate to senior developers need for production applications, from concurrency control to error handling and cancellation.
The Promise Landscape Beyond Basics
JavaScript provides four static methods on Promise for composing async operations, each suited to different scenarios:
| Method | Resolves When | Rejects When | Use Case |
|---|---|---|---|
Promise.all | All promises resolve | Any promise rejects | Parallel API calls, all required |
Promise.allSettled | All promises settle | Never | Mixed results, log all outcomes |
Promise.race | First promise settles | First promise rejects | Timeout race conditions |
Promise.any | First promise resolves | All promises reject | First-successful-response pattern |
const results = await Promise.allSettled([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/comments").then(r => r.json()),
]);
const successful = results.filter(r => r.status === "fulfilled").map(r => r.value);
Promise.allSettled is particularly useful when you need to process partial results even if some operations fail, such as batch data synchronization where individual record failures should not abort the entire batch.
Concurrency Control
Running too many Promises concurrently can overwhelm APIs, databases, or the browser. A concurrency limiter queues tasks and ensures only a specified number execute simultaneously:
async function pMap(items, fn, concurrency = 5) {
const results = [];
const executing = new Set();
for (const item of items) {
const p = fn(item).then(result => {
executing.delete(p);
return result;
});
executing.add(p);
results.push(p);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// Process 100 URLs with max 5 concurrent requests
const data = await pMap(urls, url => fetch(url).then(r => r.json()), 5);
This pattern supports backpressure: when the concurrency limit is reached, the loop pauses until at least one task completes. For more sophisticated needs, libraries such as p-limit, p-queue, and p-throttle offer additional features including priority queues and rate limiting.
Async/Await Best Practices
Sequential await in loops is a common performance pitfall. Each iteration waits for the previous Promise to resolve before starting the next, even when there are no dependencies:
// Slow: sequential execution
for (const id of ids) {
const data = await fetch(`/api/items/${id}`).then(r => r.json());
process(data);
}
// Fast: parallel execution
const results = await Promise.all(
ids.map(id => fetch(`/api/items/${id}`).then(r => r.json()))
);
results.forEach(process);
Only use sequential await when each step depends on the previous result. For independent operations, always prefer Promise.all to maximize throughput. Error handling in async functions benefits from structured try-catch with typed error classes:
class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
async function fetchData(url) {
const response = await fetch(url);
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
return response.json();
}
Error Handling and Resilience
Production applications must handle Promise rejections gracefully. Global rejection tracking catches unhandled errors:
// Browser
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled rejection:", event.reason);
event.preventDefault();
});
// Node.js
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection at:", promise, "reason:", reason);
});
For flaky external services, a circuit breaker pattern prevents repeated calls to failing endpoints:
class CircuitBreaker {
constructor(fn, options = {}) {
this.fn = fn;
this.failureCount = 0;
this.threshold = options.threshold || 5;
this.timeout = options.timeout || 30000;
this.state = "CLOSED";
}
async call(...args) {
if (this.state === "OPEN") {
throw new Error("Circuit breaker is open");
}
try {
const result = await this.fn(...args);
this.failureCount = 0;
return result;
} catch (error) {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = "OPEN";
setTimeout(() => { this.state = "HALF_OPEN"; }, this.timeout);
}
throw error;
}
}
}
Cancellation with AbortController
Promises are not inherently cancellable, but AbortController provides a standard mechanism to abort fetch requests and propagate cancellation through async flows:
function createTimeoutSignal(ms) {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
async function fetchWithTimeout(url, ms = 5000) {
const signal = createTimeoutSignal(ms);
try {
const response = await fetch(url, { signal });
return response.json();
} catch (error) {
if (error.name === "AbortError") {
throw new Error(`Request timed out after ${ms}ms`);
}
throw error;
}
}
This pattern enables timeouts for any async operation, request deduplication where in-flight requests are shared across callers, and cleanup of async flows when components unmount.
Conclusion
Advanced Promise patterns transform async JavaScript from error-prone to robust and maintainable. Choose the right Promise combinator for each scenario, limit concurrency to protect downstream services, structure async functions for readability, implement circuit breakers for resilience, and use AbortController for cancellable flows. These patterns form the foundation of production-grade asynchronous code in modern JavaScript applications.
