Callbacks and Promises

▶ Try It Yourself

Before Promises, async JavaScript was written entirely with callbacks — functions passed as arguments to be called when an operation completes. Callbacks work, but deeply nested dependent operations create “callback hell” — code that is hard to read, hard to debug, and even harder to handle errors in consistently. Promises were introduced in ES6 to solve these problems: they represent a future value and provide a clean, chainable API for sequencing async operations with unified error handling. In this lesson you will master the callback pattern, all Promise methods, chaining, error handling, and the full suite of Promise.* combinators.

Promise States

State Meaning Transitions To
Pending Initial state — operation in progress Fulfilled or Rejected
Fulfilled Operation succeeded — has a value Settled (terminal)
Rejected Operation failed — has a reason (error) Settled (terminal)

Promise Instance Methods

Method Runs When Returns
.then(onFulfilled, onRejected) Fulfilled (or either) New Promise
.catch(onRejected) Rejected — shorthand for .then(null, fn) New Promise
.finally(fn) Either state — cleanup New Promise (passes through value)

Promise Static Combinators

Method Resolves When Rejects When Use For
Promise.all(arr) ALL resolve ANY rejects — fast-fail Parallel independent requests — all required
Promise.allSettled(arr) ALL settle (either state) Never rejects Parallel requests — need all results regardless
Promise.race(arr) FIRST to settle FIRST to reject Timeout patterns, first-wins
Promise.any(arr) FIRST to fulfill ALL reject (AggregateError) Fastest successful source — fallback chains
Promise.resolve(val) Immediately Never Wrap a value in a Promise
Promise.reject(err) Never Immediately Wrap an error in a Promise
Note: Every .then() handler returns a NEW Promise. If the handler returns a value, the new Promise resolves with that value. If it returns a Promise, the new Promise waits for that Promise to settle. If it throws, the new Promise rejects. This chaining behaviour is what makes Promises composable — each step in the chain transforms or passes through the value.
Tip: Always end a Promise chain with .catch(), even in development. Unhandled Promise rejections cause warnings in browsers and crash Node.js processes (since Node 15). If you genuinely want to ignore an error for a specific operation, handle it explicitly: .catch(() => null) — this makes the intentional ignore visible in the code.
Warning: Promise.all fast-fails — if any Promise rejects, the whole thing rejects immediately and the other results are discarded. If you need all results whether they succeed or fail (e.g. a batch of API calls where partial failure is acceptable), use Promise.allSettled and check each result’s status field.

Basic Example

// ── Callback hell — the problem Promises solve ────────────────────────────
getUser(userId, (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);   // deep nesting — hard to read
        });
    });
});

// ── Creating a Promise ────────────────────────────────────────────────────
function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

function fetchUser(id) {
    return new Promise((resolve, reject) => {
        if (!id) return reject(new Error('ID required'));
        setTimeout(() => resolve({ id, name: 'Alice', role: 'admin' }), 300);
    });
}

// ── .then chaining — flat structure ──────────────────────────────────────
fetchUser(1)
    .then(user => {
        console.log('User:', user.name);
        return fetchPosts(user.id);        // return Promise to chain
    })
    .then(posts => {
        console.log('Posts:', posts.length);
        return fetchComments(posts[0].id);
    })
    .then(comments => console.log('Comments:', comments.length))
    .catch(err    => console.error('Error:', err.message))
    .finally(()   => console.log('Done — hide spinner'));

// ── Transforming values in .then ──────────────────────────────────────────
Promise.resolve([1, 2, 3, 4, 5])
    .then(nums => nums.filter(n => n % 2 === 0))  // [2, 4]
    .then(even => even.map(n => n * 10))           // [20, 40]
    .then(result => console.log(result));           // [20, 40]

// ── Promise.all — parallel independent requests ───────────────────────────
async function loadDashboard(userId) {
    const [user, posts, notifications] = await Promise.all([
        fetchUser(userId),
        fetchPosts(userId),
        fetchNotifications(userId),
    ]);
    return { user, posts, notifications };
}

// ── Promise.allSettled — tolerate partial failure ─────────────────────────
const urls = ['/api/users', '/api/posts', '/api/broken-endpoint'];

const results = await Promise.allSettled(
    urls.map(url => fetch(url).then(r => r.json()))
);

results.forEach((result, i) => {
    if (result.status === 'fulfilled') {
        console.log(`${urls[i]}: OK`, result.value);
    } else {
        console.warn(`${urls[i]}: FAILED`, result.reason.message);
    }
});

// ── Promise.race — timeout wrapper ────────────────────────────────────────
function withTimeout(promise, ms) {
    const timeout = new Promise((_, reject) =>
        setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
    );
    return Promise.race([promise, timeout]);
}

await withTimeout(fetchUser(1), 2000);   // rejects if fetchUser takes > 2s

// ── Promise.any — first successful CDN ───────────────────────────────────
const asset = await Promise.any([
    fetch('https://cdn1.example.com/script.js'),
    fetch('https://cdn2.example.com/script.js'),
    fetch('https://cdn3.example.com/script.js'),
]).then(r => r.text());
// Uses whichever CDN responds successfully first

How It Works

Step 1 — The Promise Constructor Runs Synchronously

The executor function passed to new Promise((resolve, reject) => { ... }) runs synchronously. Only resolve and reject are asynchronous — calling them schedules .then handlers as microtasks. The Promise object itself is returned synchronously before any .then runs.

Step 2 — Returning a Value vs Returning a Promise in .then

If a .then handler returns a plain value, the next .then receives that value directly. If it returns a Promise, the chain pauses until that Promise settles, then passes its resolved value downstream. If it throws, the chain jumps to the nearest .catch. This consistent behaviour is what makes Promises chainable.

Step 3 — .catch Is Not the End

.catch(fn) handles a rejection and, if it returns a value (not re-throws), the chain continues in the fulfilled state after the catch. This lets you recover from errors mid-chain: .catch(() => defaultValue) means “if this fails, continue with defaultValue.” Only re-throwing or returning a rejected Promise keeps the chain in the rejected state.

Step 4 — Promise.all Fails Fast for Required Data

Promise.all([a, b, c]) is the right tool when all three results are required and any failure should abort the whole operation. It is equivalent to “I need all of these to succeed.” The array destructuring pattern const [user, posts, likes] = await Promise.all([...]) is clean and expressive.

Step 5 — allSettled Gives You Everything Regardless

Promise.allSettled always resolves with an array of result objects — each with a status of 'fulfilled' or 'rejected', and either a value or a reason. This is ideal for batch operations where you want to process whatever succeeded and log or retry whatever failed.

Real-World Example: Data Fetcher with Retry and Cache

// data-fetcher.js

class DataFetcher {
    #cache   = new Map();
    #pending = new Map();

    async fetch(url, { retries = 3, timeout = 5000, ttl = 60_000 } = {}) {
        // Return cached result if fresh
        const cached = this.#cache.get(url);
        if (cached && Date.now() - cached.timestamp < ttl) {
            return cached.data;
        }

        // Deduplicate in-flight requests
        if (this.#pending.has(url)) {
            return this.#pending.get(url);
        }

        const request = this.#fetchWithRetry(url, retries, timeout)
            .then(data => {
                this.#cache.set(url, { data, timestamp: Date.now() });
                this.#pending.delete(url);
                return data;
            })
            .catch(err => {
                this.#pending.delete(url);
                throw err;
            });

        this.#pending.set(url, request);
        return request;
    }

    #fetchWithRetry(url, retriesLeft, timeout) {
        const controller = new AbortController();
        const timer      = setTimeout(() => controller.abort(), timeout);

        return fetch(url, { signal: controller.signal })
            .then(res => {
                clearTimeout(timer);
                if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
                return res.json();
            })
            .catch(err => {
                clearTimeout(timer);
                if (retriesLeft <= 1) throw err;
                const delay = (4 - retriesLeft) * 1000;   // 1s, 2s, 3s back-off
                console.warn(`Retrying ${url} in ${delay}ms (${retriesLeft - 1} left)`);
                return new Promise(resolve => setTimeout(resolve, delay))
                    .then(() => this.#fetchWithRetry(url, retriesLeft - 1, timeout));
            });
    }

    invalidate(url) { this.#cache.delete(url); }
    clearCache()    { this.#cache.clear(); }
}

const fetcher = new DataFetcher();

// All three fire in parallel; result is deduped if called again before resolved
const [users, posts, tags] = await Promise.all([
    fetcher.fetch('/api/users'),
    fetcher.fetch('/api/posts'),
    fetcher.fetch('/api/tags'),
]);

Common Mistakes

Mistake 1 — Forgetting to return inside .then

❌ Wrong — next .then receives undefined:

fetchUser(1)
    .then(user => { fetchPosts(user.id); })  // no return!
    .then(posts => console.log(posts));       // posts = undefined

✅ Correct — return the Promise to chain it:

fetchUser(1)
    .then(user => fetchPosts(user.id))   // return the Promise
    .then(posts => console.log(posts));  // posts = actual data

Mistake 2 — Using Promise.all when partial failure is acceptable

❌ Wrong — one failed request discards all results:

const results = await Promise.all(urls.map(fetch));
// If any URL 404s, entire result is lost

✅ Correct — use allSettled for fault-tolerant batch requests:

const results = await Promise.allSettled(urls.map(fetch));
const successes = results.filter(r => r.status === 'fulfilled');

Mistake 3 — Nesting .then instead of chaining

❌ Wrong — recreates callback hell inside Promises:

fetchUser(1).then(user => {
    fetchPosts(user.id).then(posts => {    // nested!
        fetchComments(posts[0].id).then(comments => { ... });
    });
});

✅ Correct — return and chain flat:

fetchUser(1)
    .then(user  => fetchPosts(user.id))
    .then(posts => fetchComments(posts[0].id))
    .then(comments => { ... });

▶ Try It Yourself

Quick Reference

Task Code
Create Promise new Promise((resolve, reject) => { ... })
Chain success .then(value => transform(value))
Handle error .catch(err => handleError(err))
Cleanup always .finally(() => hideSpinner())
Parallel — all required Promise.all([a, b, c])
Parallel — tolerate failure Promise.allSettled([a, b, c])
First to succeed Promise.any([a, b, c])
First to settle Promise.race([p, timeout])
Wrap value Promise.resolve(value)

🧠 Test Yourself

You fetch data from three endpoints in parallel. One may fail but you still want results from the others. Which combinator should you use?





▶ Try It Yourself