Three lines of .then() look fine. Twelve lines of nested .then().catch().finally() look like archaeology. The shape of async code in JavaScript is misleading because the easy version doesn't show you the failure modes.
This is about the patterns where promises stop being simple — and what to reach for when they do.
Promise Chains Hide Flow When They Grow
A linear chain reads okay. A chain with branching, conditional steps, and error recovery reads like a maze.
fetchUser(id)
.then(user => fetchOrders(user.id))
.then(orders => orders.filter(o => o.active))
.then(orders => orders.map(o => fetchItems(o.id)))
.then(promises => Promise.all(promises))
.catch(err => console.error(err))
.finally(() => stopLoading());
Five .thens in, you can no longer easily answer "where does this throw?" or "which step's data is in scope here?" The chain succeeded as a refactoring tool but failed as a way to express intent.
async/await Improves Shape But Not Semantics
async/await is the same machinery with better readability. The same code:
try {
const user = await fetchUser(id);
const orders = await fetchOrders(user.id);
const active = orders.filter(o => o.active);
const items = await Promise.all(active.map(o => fetchItems(o.id)));
return items;
} catch (err) {
console.error(err);
} finally {
stopLoading();
}
Linear, readable, normal-looking. But async/await doesn't change the underlying behavior — it's still promises underneath, still subject to microtask ordering, still throws if you forget to await (silent unhandled rejection).
The trap: a function that returns a promise without awaiting it loses error context. fetchUser(id) without await and without .catch swallows errors silently.
Parallel Work Needs Clear Failure Rules
When several async operations run together, the failure semantics matter more than the success path. The same input — three promises — produces four very different outcomes depending on which combinator you pick:
// All-or-nothing: rejects on the first failure, the rest are wasted
const [user, orders, prefs] = await Promise.all([
fetchUser(id), fetchOrders(id), fetchPrefs(id)
]);
// Fault-tolerant: gives you the result of each, success or failure
const results = await Promise.allSettled([
fetchUser(id), fetchOrders(id), fetchPrefs(id)
]);
results.forEach(r => {
if (r.status === 'fulfilled') use(r.value);
else log(r.reason);
});
Use Promise.all only when you genuinely cannot continue without every result. Use Promise.allSettled when partial UI is acceptable. The wrong choice here is one of the most common production bugs in dashboards: Promise.all and one slow widget breaks the entire page.
Cancellation Needs Explicit Design
Promises do not have built-in cancellation. Once started, they run to completion and there's no way to tell them "never mind." The fix is AbortController, which most modern APIs accept as a signal:
const controller = new AbortController();
const signal = controller.signal;
try {
const res = await fetch(url, { signal });
const data = await res.json();
apply(data);
} catch (err) {
if (err.name === 'AbortError') return; // expected
throw err;
}
// later: cancel from anywhere
controller.abort();
This is essential for search inputs (cancel previous query when user types again), component unmount in React (cancel in-flight requests so the result doesn't apply to a dead component), and timeout patterns (AbortSignal.timeout(5000)).
Error Handling Has Three Layers
Production-grade async code usually has three error layers:
- Per-call —
try/catcharound eachawaitwhere you want specific recovery (retry, fallback, default value). - Per-feature — a wrapping
try/catchthat translates errors to UI state (toast, error boundary). - Global —
unhandledrejectionlistener that logs anything that escaped both layers, so you find out about it.
window.addEventListener('unhandledrejection', (event) => {
reportError(event.reason);
});
Without the global net, async errors that escape your try/catch blocks just disappear from the console in production builds.
Pro Tips
- Always
awaitor.catch. A floating promise is a silent failure waiting to happen. - Pick the right combinator.
allfor all-or-nothing,allSettledfor partial UI,racefor first result,anyfor first success. - Wire cancellation through
AbortSignal. Every fetch, every timer, every long-running task. - Add a global
unhandledrejectionhandler. It's your safety net. - Don't mix
.thenchains withawaitin the same function. Pick one shape.
Final Tips
The honest version: promises are simple when one promise resolves successfully. They get complicated when several need to coordinate, when one might fail, when the user cancels, or when the component unmounts. Production code spends most of its complexity on those cases.
Pick the combinator that matches your failure rule. Wire cancellation in early. Catch errors at the layer that can actually do something about them.
Good luck — and may your unhandled rejections be few 👊




