, ,

How to Chain Promises Like a Boss

Posted by

Practical patterns for sequencing, parallelizing, error-handling, and hardening async flows — without the pyramid of doom.

Practical patterns for sequencing, parallelizing, error-handling, and hardening async flows — without the pyramid of doom.

Introduction

Promises are the backbone of modern JavaScript async. But the how — sequencing multiple operations, combining results, handling errors cleanly, adding timeouts/retries, limiting concurrency — still trips up even seasoned devs.

This guide is a hands-on playbook. You’ll learn exactly how to chain Promises for real projects: from simple “do A then B” to “kick off 50 calls with a pool of 5,” plus rock-solid error handling, cancellation, and production safeguards. I’ll keep it conversational, with copy-paste snippets and gotchas highlighted.


1) Promise Chaining in 90 Seconds

  • A Promise represents an async result that will either fulfill (resolve) or reject.
  • .then(onFulfilled, onRejected) returns a new Promise. Whatever you return from onFulfilled (or onRejected) becomes the next Promise’s value. Throwing an error rejects the next promise.
  • .catch(onRejected) is sugar for .then(undefined, onRejected).
  • .finally(cb) runs regardless of success/failure; it does not receive the value—use it for cleanup.

Core mental model:

doA()
.then(resultA => doB(resultA)) // return → chains to next
.then(resultB => doC(resultB)) // each step returns a promise or value
.catch(err => handle(err)) // any prior rejection lands here
.finally(() => cleanup()); // runs either way

Rule: Always either return a value/promise or throw inside .then/.catch. Silent fallthrough = surprise bugs.


2) Sequencing: Waterfalls Without Tears

Pattern: Linear chain

fetchUser(id)
.then(user => getProfile(user))
.then(profile => enrich(profile))
.then(enriched => save(enriched))
.then(saved => notify(saved.email))
.catch(reportError);

Equivalent with async/await (same semantics)

try {
const user = await fetchUser(id);
const profile = await getProfile(user);
const enriched = await enrich(profile);
const saved = await save(enriched);
await notify(saved.email);
} catch (err) {
reportError(err);
}

When to use: Each step depends on the previous result (e.g., auth → fetch → transform → persist).

Gotcha (classic): Forgetting to return a promise inside .then:

// ❌ WRONG: inner promise is unchained; next then runs too early
doA().then(a => { doB(a); }).then(...);

// ✅ RIGHT:
doA().then(a => { return doB(a); }).then(...);
// or concise:
doA().then(doB).then(...);

3) Parallelism: Finish Faster with Promise.all

Start independent work first, then await together.

const userP   = fetchUser(id);
const postsP = fetchPosts(id);
const prefsP = fetchPreferences(id);

Promise.all([userP, postsP, prefsP])
.then(([user, posts, prefs]) => render({ user, posts, prefs }))
.catch(reportError);

Why this rocks: True concurrency for I/O-bound tasks. The slowest promise determines total time.

Fail-fast behavior: If any member rejects, Promise.all rejects immediately and cancels nothing (JS promises have no automatic cancellation). Use AbortController if you must abort in-flight work (see §10).


4) Soft-Fail Aggregation with Promise.allSettled

When you want all outcomes, regardless of failures:

const outcomes = await Promise.allSettled(urls.map(fetchJson));
for (const o of outcomes) {
if (o.status === "fulfilled") use(o.value);
else log(o.reason);
}

Use case: Bulk jobs, dashboards, where partial results still matter.


5) Who Wins First? Promise.race vs Promise.any

  • Promise.race([p1, p2, ...]) settles with the first to settle (fulfilled or rejected).
  • Promise.any([p1, p2, ...]) settles with the first to fulfill (ignores rejections until all reject; then throws AggregateError).
// race: first settled wins (use for timeouts)
Promise.race([
fetch(url),
timeout(3000) // a rejecting promise
]).then(handle).catch(report);

// any: first success wins
Promise.any(mirrors.map(m => fetchFromMirror(m)))
.then(useFastest)
.catch(e => console.error('All mirrors failed', e.errors));

6) Composition: Promise-Powered Pipelines

Make small functions that return a promise/value. Then compose.

const parseJSON = (r) => r.json();
const ensure200 = (r) => r.ok ? r : Promise.reject(new Error('HTTP ' + r.status));
const pickName = (u) => u.name;

fetch('/api/user/42')
.then(ensure200)
.then(parseJSON)
.then(pickName)
.then(console.log)
.catch(reportError);

Tip: If a step might throw synchronously, that throw automatically becomes a rejection in the chain. No try/catch needed here—handle at the end.


7) Branching Chains Without Duplicate Code

Pattern: Conditional continuation

getOrder(id)
.then(order => {
if (order.status === 'PAID') {
return fulfill(order); // chain continues with fulfill’s promise
} else {
return charge(order).then(() => fulfill(order));
}
})
.then(sendReceipt)
.catch(reportError);

Pattern: Early exit (resolve to a sentinel)

getFeatureFlag('beta')
.then(flag => flag ? loadBeta() : 'SKIPPED')
.then(result => {
if (result !== 'SKIPPED') initBetaUI(result);
});

8) Concurrency Limits: Don’t DDOS Your Backend

Goal: Kick off many tasks but only N at a time.

Tiny pool utility

function limit(fn, pool = 5) {
let active = 0, queue = [];
const run = (...args) => new Promise((resolve, reject) => {
const task = async () => {
active++;
try { resolve(await fn(...args)); }
catch (e) { reject(e); }
finally {
active--;
if (queue.length) queue.shift()();
}
};
active < pool ? task() : queue.push(task);
});
return run;
}

Use it:

const fetchLimited = limit(fetchJson, 5);
const results = await Promise.all(urls.map(u => fetchLimited(u)));

Why this matters: APIs have rate limits; your users have bandwidth/CPU limits.


9) Production Hardening: Timeouts, Retries, Backoff

Timeout wrapper

const withTimeout = (p, ms, msg = 'Timeout') =>
new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(msg)), ms);
p.then(v => { clearTimeout(t); res(v); },
e => { clearTimeout(t); rej(e); });
});

Exponential backoff retry

async function retry(fn, { retries = 3, base = 200, factor = 2 } = {}) {
let attempt = 0, last;
while (attempt++ <= retries) {
try { return await fn(); }
catch (e) {
last = e;
if (attempt > retries) break;
await new Promise(r => setTimeout(r, base * factor ** (attempt - 1)));
}
}
throw last;
}

Combine them:

await retry(() => withTimeout(fetchJson(url), 4000), { retries: 2, base: 300 });

Pro tip: Fail fast on non-retryable errors (e.g., 4xx). Only retry transient errors (5xx, network).


10) Cancellation: Promises Don’t Cancel — Your Code Should

Use AbortController with APIs that support it (fetch, many libs).

const controller = new AbortController();
const p = fetch(url, { signal: controller.signal }).then(r => r.json());

// later (user navigates away):
controller.abort(); // rejects with DOMException: "AbortError"

In chains: pass the same signal through, and check signal.aborted where needed. If a race finishes early, abort the losers:

const c1 = new AbortController();
const c2 = new AbortController();

const fast = Promise.any([
fetch(mirror1, { signal: c1.signal }),
fetch(mirror2, { signal: c2.signal })
]);

fast.finally(() => { c1.abort(); c2.abort(); }); // cleanup losers

11) Error Handling: Centralized, Informative, Fair

Single .catch for linear chains

doA()
.then(doB)
.then(doC)
.catch(err => {
console.error('Pipeline failed', err);
// decide to swallow, rethrow, or transform
throw err; // or return a fallback
});

Localized recovery

fetchJson(url)
.catch(e => (e.status === 404 ? {} : Promise.reject(e)))
.then(render);

Preserve context

.doSomething()
.then(...)
.catch(e => {
e.context = { userId, url };
throw e;
})
.catch(reportWithContext);

finally for cleanup

connect()
.then(conn => query(conn).finally(() => conn.close()))
.then(useResults)
.catch(reportError);

Remember: finally doesn’t receive the value; it passes through the prior outcome (unless it throws).


12) Common Gotchas (and Fixes)

❌ Forgetting return inside .then

doA().then(a => { doB(a); }).then(use); // use runs too early
// ✅
doA().then(a => doB(a)).then(use);

❌ Using forEach with async

// forEach ignores returned promises
urls.forEach(async u => await fetch(u)); // tasks fire, but you can't await completion
// ✅ Use map + Promise.all:
await Promise.all(urls.map(u => fetch(u)));

❌ Accidental serialization

for (const u of urls) await fetch(u); // sequential.
// ✅
await Promise.all(urls.map(u => fetch(u)));

❌ Swallowing errors in .catch

doA().catch(e => console.log(e)).then(stepB); // stepB runs even if A failed
// ✅ Rethrow or return a fallback intentionally:
doA().catch(e => { log(e); throw e; }).then(stepB);

❌ Starving the loop with microtasks

Creating enormous promise chains that resolve synchronously can starve timers/UI. Break it up:

await Promise.resolve(); // yield a tick (or queueMicrotask)

13) Patterns You’ll Reuse

13.1 Map → Chain (transform then act)

fetchJson(url)
.then(validate)
.then(normalize)
.then(save)
.catch(reportError);

13.2 Reduce → Waterfall (sequential over a list)

await urls.reduce(
(p, url) => p.then(() => fetchJson(url).then(save)),
Promise.resolve()
);

13.3 Batch with backpressure

const doLimited = limit(fetchJson, 4);
const results = await Promise.all(items.map(x => doLimited(x)));

13.4 Circuit breaker (simple)

function breaker(fn, { failMax = 5, coolMs = 10_000 } = {}) {
let fails = 0, openUntil = 0;
return (...args) => {
const now = Date.now();
if (now < openUntil) return Promise.reject(new Error('circuit open'));
return fn(...args).catch(e => {
if (++fails >= failMax) { openUntil = now + coolMs; fails = 0; }
throw e;
});
};
}

14) End-to-End Example: From Callback Pyramid → Clean Chain

Before (callback hell):

getUser(id, (e, user) => {
if (e) return next(e);
getPermissions(user, (e, perms) => {
if (e) return next(e);
getTeam(user.teamId, (e, team) => {
if (e) return next(e);
sendEmail(user.email, team, perms, (e) => {
if (e) return next(e);
res.json({ ok: true });
});
});
});
});

After (chained promises):

const p = (fn) => (...args) => new Promise((res, rej) =>
fn(...args, (e, v) => e ? rej(e) : res(v))
);

const getUserP = p(getUser);
const getPermissionsP = p(getPermissions);
const getTeamP = p(getTeam);
const sendEmailP = p(sendEmail);

getUserP(id)
.then(user => Promise.all([user, getPermissionsP(user), getTeamP(user.teamId)]))
.then(([user, perms, team]) => sendEmailP(user.email, team, perms))
.then(() => res.json({ ok: true }))
.catch(next);

Readable, parallel where safe, single .catch for centralized error handling.


15) Quick Reference: Which Combo Do I Use?


16) Copy-Paste Utilities

export const limit = (fn, pool = 5) => {
let active = 0, queue = [];
const run = (...args) => new Promise((resolve, reject) => {
const task = async () => {
active++;
try { resolve(await fn(...args)); }
catch (e) { reject(e); }
finally {
active--;
if (queue.length) queue.shift()();
}
};
active < pool ? task() : queue.push(task);
});
return run;
};

export const withTimeout = (p, ms, msg = 'Timeout') =>
new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(msg)), ms);
p.then(v => { clearTimeout(t); res(v); },
e => { clearTimeout(t); rej(e); });
});

export const retry = async (fn, { retries = 3, base = 200, factor = 2 } = {}) => {
let attempt = 0, last;
while (attempt++ <= retries) {
try { return await fn(); }
catch (e) {
last = e;
if (attempt > retries) break;
await new Promise(r => setTimeout(r, base * factor ** (attempt - 1)));
}
}
throw last;
};

export const timeout = (ms, msg = 'Timeout') =>
new Promise((_, rej) => setTimeout(() => rej(new Error(msg)), ms));

17) Testing Chained Promises (No Flakes)

  • Return or await the promise in your test so the runner knows when it’s done.
  • Mock network/filesystem; don’t hit real services.
  • Use fake timers for timeouts/retries.
  • Assert success with resolves, failure with rejects.
await expect(fetchUser(id)).resolves.toMatchObject({ id });
await expect(withTimeout(delay(10), 5)).rejects.toThrow(/Timeout/);

Conclusion

Chaining promises “like a boss” boils down to a few fundamentals:

  • Return results from .then, throw to reject, and keep a single .catch when you can.
  • Parallelize independent work with Promise.all; serialize only when there’s a dependency.
  • Use the right combiner (all, allSettled, race, any) for the job.
  • Add timeouts, retries, cancellation, and concurrency limits for production-grade reliability.
  • Prefer small, composable functions that return promises or values; chain them like Lego.

Pro tip: When an async flow misbehaves, say out loud: “What does this .then return? Where does rejection go? Am I starting everything first, or serializing accidentally?” That debug mantra pays dividends.


Call to Action

What’s your favorite promise pattern — Promise.any for mirrors, a concurrency pool, or a clever race timeout?
💬 Share your snippet in the comments.
🔖 Bookmark this as your async toolkit.
👩‍💻 Send it to a teammate still wrestling with nested callbacks.

Leave a Reply

Your email address will not be published. Required fields are marked *