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 fromonFulfilled
(oronRejected
) 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 throwsAggregateError
).
// 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 withrejects
.
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