JavaScript Promises and Async/Await
Asynchronous programming is central to JavaScript. Promises and async/await provide powerful abstractions for managing async operations without the callback pyramid of doom. This guide covers the full spectrum of async JavaScript.
The Problem: Callback Hell
// Callback-based code (hard to read, error-prone)
getUser(id, (err, user) => {
if (err) return handleError(err);
getPosts(user.id, (err, posts) => {
if (err) return handleError(err);
getComments(posts[0].id, (err, comments) => {
if (err) return handleError(err);
render({ user, posts, comments });
});
});
---);This nesting grows horizontally with each async operation, making error handling, cancellation, and parallel execution difficult.
Promises to the Rescue
// Promise-based (flat chain)
getUser(id)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => render(comments))
.catch(error => handleError(error));A Promise represents a value that may be available now, later, or never. It has three states: pending, fulfilled, or rejected.
Creating Promises
function delay(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
---
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error('Invalid user ID'));
} else {
resolve({ id, name: 'Alice' });
}
}, 1000);
});
---
// Usage
fetchUser(1)
.then(user => console.log(user))
.catch(error => console.error(error));The executor function receives resolve and reject callbacks. Call resolve(value) when the operation succeeds. Call reject(error) when it fails. The executor runs synchronously when the Promise is created.
Promise States
const promise = new Promise((resolve, reject) => {
// executor runs immediately
console.log('Executor running');
setTimeout(() => {
resolve('done');
resolve('ignored'); // second resolve has no effect
}, 1000);
---);
console.log(promise); // Promise { <pending> }
// After 1s: Promise { <fulfilled>: 'done' }
A Promise is settled (fulfilled or rejected) exactly once. Subsequent calls to resolve or reject are silently ignored.
Promise Chaining
fetchUser(1)
.then(user => {
console.log('User:', user);
return fetchPosts(user.id); // return another promise
})
.then(posts => {
console.log('Posts:', posts);
// return a value — wraps in Promise.resolve
return posts.length;
})
.then(count => {
console.log('Post count:', count);
})
.catch(error => {
console.error('Any error in the chain:', error);
});Each .then returns a new Promise. The return value from the callback determines how the chain continues:
- Return a value → next
.thenreceives that value - Return a Promise → chain waits for it to settle
- Throw / return rejected promise → chain skips to next
.catch
Error Handling
fetchUser(1)
.then(user => fetchPosts(user.id))
.then(posts => {
if (posts.length === 0) {
throw new Error('No posts found'); // caught by catch
}
return posts;
})
.catch(error => {
console.error('Operation failed:', error.message);
return fallbackData; // recover gracefully
})
.finally(() => {
console.log('Cleanup runs regardless of success or failure');
});The .catch at the end of a chain catches any rejection or thrown error in any preceding .then. A .catch that returns a value resumes the chain as fulfilled. finally runs regardless and does not change the resolved/rejected value.
Async/Await
Async/await is syntactic sugar over Promises that makes async code read like synchronous code:
async function loadUserData(userId) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
return { user, posts, comments };
} catch (error) {
console.error('Failed to load user data:', error);
throw error; // re-throw for caller
} finally {
console.log('Cleanup');
}
---
// Usage
const data = await loadUserData(1);
console.log(data);await pauses execution of the async function until the Promise settles. It does not block the event loop — other JavaScript continues running. Async functions always return a Promise.
Sequential vs Parallel
// Sequential (slow)
const user = await fetchUser(id);
const posts = await fetchPosts(user.id);
// Total time: fetchUser + fetchPosts
// Parallel (fast)
const [user, posts] = await Promise.all([
fetchUser(id),
fetchPosts(id),
]);
// Total time: max(fetchUser, fetchPosts)
Use Promise.all when operations are independent. Use sequential await when operations depend on each other’s results.
Promise Combinators
// Promise.all — all or nothing (short-circuits on rejection)
const results = await Promise.all([
fetchUser(1),
fetchPosts(1),
fetchComments(1),
]);
// Promise.allSettled — wait for all (never rejects)
const outcomes = await Promise.allSettled([
fetchUser(1),
fetchPosts(999), // will reject
]);
for (const outcome of outcomes) {
if (outcome.status === 'fulfilled') {
console.log('Success:', outcome.value);
} else {
console.log('Failure:', outcome.reason);
}
---
// Promise.race — first settled (resolve or reject) wins
const result = await Promise.race([
fetch(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000)
),
]);
// Promise.any — first fulfilled (ignores rejections until all fail)
const firstSuccess = await Promise.any([
fetchFromCDN(),
fetchFromOrigin(),
fetchFromBackup(),
]);Converting Callbacks to Promises
// Node-style callback
function readFile(path, encoding, callback) {
// ... Node.js fs.readFile
---
// Promisify
const readFilePromise = (path, encoding) => {
return new Promise((resolve, reject) => {
readFile(path, encoding, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
---;
// Node.js built-in promisify (util.promisify)
import { promisify } from 'util';
const readFileAsync = promisify(readFile);Common Patterns
Retry with Backoff
async function fetchWithRetry(url, retries = 3, delay = 1000) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (attempt === retries) throw error;
await new Promise(r => setTimeout(r, delay * attempt));
}
}
---Timeout
function withTimeout(promise, ms, errorMessage = 'Operation timed out') {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(errorMessage)), ms)
);
return Promise.race([promise, timeout]);
---Caching
const cache = new Map();
async function fetchWithCache(url, ttl = 60000) {
const cached = cache.get(url);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
}
const data = await fetch(url).then(r => r.json());
cache.set(url, { data, timestamp: Date.now() });
return data;
---Sequential vs Parallel Patterns
Choosing the right concurrency pattern:
// Sequential waterfall — each step depends on previous
async function sequential(userId) {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
return { user, posts, comments };
---
// Parallel fan-out — independent operations
async function parallel(userId) {
const [user, posts, notifications] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId),
]);
return { user, posts, notifications };
---
// Throttled concurrency — limit parallel operations
async function throttled(tasks, concurrency = 3) {
const results = [];
const queue = [...tasks];
async function worker() {
while (queue.length > 0) {
const task = queue.shift();
results.push(await task());
}
}
const workers = Array(concurrency).fill().map(() => worker());
await Promise.all(workers);
return results;
---
// Map with concurrency limit
async function mapConcurrent(items, fn, concurrency = 3) {
const results = [];
const executing = new Set();
for (const item of items) {
const promise = fn(item).then(result => {
executing.delete(promise);
return result;
});
results.push(promise);
executing.add(promise);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
return Promise.all(results);
---Promise-based Retry with Exponential Backoff
async function retryWithBackoff(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 10000,
factor = 2,
} = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
const delay = Math.min(
baseDelay * Math.pow(factor, attempt - 1),
maxDelay
);
// Add jitter to prevent thundering herd
const jitter = delay * (0.5 + Math.random() * 0.5);
console.warn(`Attempt ${attempt} failed, retrying in ${jitter}ms`);
await new Promise(r => setTimeout(r, jitter));
}
}
---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 difference between Promise.all and Promise.allSettled?
A: Promise.all rejects immediately if any promise rejects (short-circuit). Promise.allSettled waits for all promises to settle and reports each outcome as {status: 'fulfilled', value} or {status: 'rejected', reason}.
Q: When should I use Promise.race vs Promise.any?
A: Promise.race settles on the first settled promise (resolve or reject). Use it for timeouts. Promise.any settles on the first fulfilled promise and only rejects if all reject. Use it for racing multiple sources.
Q: Can I use await outside an async function? A: Top-level await works in ES modules (script type=“module” or .mjs files). In CommonJS scripts, you need an async wrapper.
Q: What happens if I don’t catch a rejection? A: In Node.js 15+, unhandled rejections terminate the process. In browsers, unhandled rejections are logged to the console. Always handle rejections.
Q: How do I cancel a promise?
A: Promises are not cancellable by nature. Use AbortController with fetch or custom cancellation tokens for long-running async operations.
Q: What is the difference between microtasks and macrotasks in promises?
A: Promise .then callbacks run as microtasks, which have higher priority than macrotasks (setTimeout, setInterval). This is why Promise-based code executes before timer callbacks.
For a comprehensive overview, read our article on Javascript Arrays Guide.
For a comprehensive overview, read our article on Javascript Async Await.