JavaScript Promises: A Complete Guide
Promises are the foundation of modern asynchronous JavaScript. They represent a value that may be available now, in the future, or never. This guide covers the Promise API in depth, from creation to advanced patterns.
Promise Basics
A Promise is an object with three states:
const promise = new Promise((resolve, reject) => {
// Pending → Fulfilled (resolve called)
// Pending → Rejected (reject called or error thrown)
---);
promise.then(
(value) => console.log('Fulfilled:', value),
(error) => console.log('Rejected:', error)
);Creating Promises
// Wrapping a callback-based API
function readFileAsync(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
---
// Already settled promises
const fulfilled = Promise.resolve('immediate value');
const rejected = Promise.reject(new Error('immediate error'));
// Use Promise.reject sparingly — it creates an unhandled rejection if not caught
// Timers
const delay = Promise.resolve().then(() => {
return new Promise(resolve => setTimeout(resolve, 1000));
---);Promise Chaining
Chaining is the primary way to compose async operations:
// Three styles of the same pattern
// 1. Thenable chain
fetchUser(id)
.then(fetchProfile)
.then(fetchPosts)
.then(renderProfile)
.catch(handleError);
// 2. Returning arrays
Promise.all([fetchUser(id), fetchConfig()])
.then(([user, config]) => ({ user, config }))
.then(data => render(data));
// 3. Flat chain with error recovery
fetchUser(id)
.catch(() => ({ id, name: 'Guest' })) // recover from error
.then(user => fetchPosts(user.id))
.catch(() => []) // recover: return empty array
.then(posts => render({ user, posts }));Each .then returns a new Promise:
- If the callback returns a non-thenable value, the Promise fulfills with that value
- If the callback returns a Promise, the chain waits and adopts its state
- If the callback throws, the Promise rejects with the thrown error
Error Recovery in Chains
fetchUser(id)
.then(user => fetchPosts(user.id))
.catch(error => {
console.warn('Failed to fetch posts, using empty array:', error);
return []; // recover — chain continues as fulfilled
})
.then(posts => render({ posts }));A .catch that returns a value (or a fulfilled promise) resumes the chain as fulfilled. This is the Promise equivalent of try-catch with a fallback value.
Concurrency Methods
Promise.all
Run promises in parallel and collect all results. Rejects if any reject (short-circuit):
async function loadDashboard(userId) {
const [user, posts, notifications] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId),
]);
return { user, posts, notifications };
---Promise.all is the standard pattern for fan-out parallelism in JavaScript. The total time is the max of all individual times, not the sum.
Promise.allSettled
Wait for all promises regardless of individual outcomes:
const results = await Promise.allSettled([
fetchUser(1),
fetchPosts(999), // will reject
fetchConfig(),
]);
for (const result of results) {
if (result.status === 'fulfilled') {
console.log('Success:', result.value);
} else {
console.warn('Failure:', result.reason);
// Continue processing — no overall rejection
}
---Use allSettled when partial success is acceptable and you need to process all results individually.
Promise.race
Settles when the first promise settles (resolve or reject):
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
);
return Promise.race([promise, timeout]);
---
const data = await withTimeout(fetch(url), 5000);Promise.any
Settles when the first promise fulfills. Rejects only if all reject:
const fastestSource = await Promise.any([
fetchFromCDN(),
fetchFromOrigin(),
fetchFromMirror(),
]).catch(() => {
throw new Error('All sources failed');
---);Error Handling Patterns
// Centralized error handling
class AppError extends Error {
constructor(message, code, context) {
super(message);
this.code = code;
this.context = context;
}
---
// Error boundary wrapper
async function withErrorHandling(fn, context) {
try {
return await fn();
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError('Operation failed', 500, {
original: error,
context,
});
}
---Async/Await Error Handling
async function safeOperation() {
try {
const data = await riskyOperation();
return data;
} catch (error) {
if (error instanceof NetworkError) {
return await fallbackOperation();
}
throw error; // re-throw unknown errors
}
---Promise Anti-Patterns
// ❌ Explicit Promise construction when not needed
new Promise((resolve) => {
fs.readFile(path, (err, data) => {
if (err) reject(err);
else resolve(data);
});
---);
// ✅ Just use the promise-returning API
fs.promises.readFile(path);
// ❌ The explicit promise construction antipattern
function fetchData() {
return new Promise((resolve) => {
fetch(url).then(resolve);
});
---
// ✅ Just return the promise
function fetchData() {
return fetch(url).then(r => r.json());
---
// ❌ Nesting promises (promise pyramid)
fetchUser().then(user => {
fetchPosts(user.id).then(posts => {
console.log(posts);
});
---);
// ✅ Flat chain
fetchUser()
.then(user => fetchPosts(user.id))
.then(posts => console.log(posts));Promise Inspection
// There's no standard way to check a promise's state synchronously.
// But you can track state yourself:
function trackable(promise) {
let state = 'pending';
const tracked = promise
.then(value => { state = 'fulfilled'; return value; })
.catch(error => { state = 'rejected'; throw error; });
tracked.state = () => state;
return tracked;
---Promise Composition Patterns
Sequential Execution with Dynamic Inputs
async function processInSequence(items) {
const results = [];
for (const item of items) {
// Each iteration waits for the previous to complete
const result = await processItem(item);
results.push(result);
}
return results;
---
// Using reduce for promise chaining
function sequentialReduce(items) {
return items.reduce((promise, item) => {
return promise.then(results => {
return processItem(item).then(result => {
return [...results, result];
});
});
}, Promise.resolve([]));
---Batching for Rate Limits
async function batchProcess(items, batchSize = 5) {
const results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(item => processItem(item))
);
results.push(...batchResults);
}
return results;
---Promise with Timeout and Cancellation
function cancellablePromise(fn) {
let cancel = null;
const promise = new Promise((resolve, reject) => {
cancel = () => reject(new Error('Cancelled'));
fn(resolve, reject);
});
return { promise, cancel };
---
// Usage
const { promise, cancel } = cancellablePromise((resolve) => {
setTimeout(() => resolve('Done'), 5000);
---);
// Cancel after 2 seconds
setTimeout(() => cancel(), 2000);
promise.catch(err => console.log(err.message)); // 'Cancelled'
Real-World Implementation Tips
Production Considerations
When moving from development to production, several factors become critical. Error handling should be comprehensive — every external call (database, API, file system) should have proper error checking, logging, and retry logic where appropriate. Performance monitoring through metrics and structured logging helps identify bottlenecks before they affect users.
Testing Strategy
A thorough testing approach combines multiple levels:
- Unit tests verify individual functions and methods in isolation
- Integration tests validate that components work together correctly
- Edge case tests cover boundary conditions, empty inputs, and error states
- Performance tests ensure the system meets latency and throughput requirements
Test data should be realistic but controlled. Mock external dependencies to make tests fast and deterministic. Aim for tests that are independent, repeatable, and fast enough to run on every commit.
Documentation
Good documentation is essential for maintainable code. Follow these principles:
- Document the “why” not just the “what” — explain design decisions
- Keep examples up to date with the code
- Include usage examples for public APIs
- Document configuration options and their defaults
- Explain error conditions and recovery strategies
Security Best Practices
Security should be considered throughout development:
- Validate all inputs at system boundaries
- Use parameterized queries for database access
- Store secrets in environment variables or secret managers
- Keep dependencies updated to patch vulnerabilities
- Apply the principle of least privilege
Performance Optimization
Optimize based on measured data, not assumptions:
- Profile before optimizing — identify actual bottlenecks
- Measure the impact of each change
- Consider the trade-off between speed and readability
- Cache expensive operations with appropriate invalidation
- Use connection pooling for database and network resources
Monitoring and Observability
Production systems need visibility:
- Structured logging with correlation IDs for request tracking
- Metrics for latency, throughput, error rates, and resource usage
- Health check endpoints for load balancers and orchestration
- Distributed tracing for request flows across services
- Alerts for anomaly detection based on baselines
These patterns apply across all programming languages and frameworks. The specific implementation varies, but the principles remain consistent.
FAQ
Q: What is the microtask queue and how does it relate to promises?
A: Promise .then callbacks are scheduled as microtasks. The microtask queue is drained after each macrotask (event loop tick) and before rendering. This is why promise callbacks run before setTimeout callbacks.
Q: Can I cancel a promise? A: Promises cannot be cancelled natively. Use AbortController for fetch requests or custom cancellation tokens for long-running async operations.
Q: What happens if I don’t attach a catch handler? A: In Node.js 15+, unhandled promise rejections crash the process. In browsers, they generate console warnings. Always handle rejections.
Q: What is the difference between throw in a promise executor vs reject?
A: Syntactically both work — throwing in the executor is equivalent to calling reject. But throw must be synchronous; reject can be called asynchronously (inside a setTimeout, for example).
Q: How do I promise.all with a dynamic number of promises?
A: Collect them in an array: const promises = items.map(item => process(item)); const results = await Promise.all(promises);
Q: What is Promise.resolve doing?
A: It creates a promise that is already fulfilled with the given value. If the value is a thenable (has a .then method), it unwraps it — effectively converting any thenable to a native Promise.
For a comprehensive overview, read our article on Javascript Arrays Guide.
For a comprehensive overview, read our article on Javascript Async Await.