Skip to content

JavaScript Promises: A Complete Guide

JavaScript JavaScript 8 min read 1513 words Beginner ExcellentWiki Editorial Team

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:

  1. Profile before optimizing — identify actual bottlenecks
  2. Measure the impact of each change
  3. Consider the trade-off between speed and readability
  4. Cache expensive operations with appropriate invalidation
  5. 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.

Section: JavaScript 1513 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top