Promise Hell: When Chaining Turns Back Into Nesting

How a rejection skips every step that doesn't handle it until something catches it, and the nesting mistake that quietly turns a clean promise chain back into a pyramid.

September 6, 20262 min read15 / 17

Here's the actual idea this post teaches, in one sentence: a rejection anywhere in a promise chain skips every step that doesn't handle it, all the way down to the nearest .catch(), and nesting .then() calls instead of returning promises back up the chain recreates the exact pyramid callbacks had, a mistake with its own name: promise hell.

An Error Skips Straight to the Nearest Catch

JavaScript
function getStat(name, shouldFail) { return new Promise((resolve, reject) => { setTimeout(() => { if (shouldFail) reject(new Error(`${name} failed`)); else resolve(`${name} data ready`); }, 100); }); } function output(text) { console.log(text); } getStat('inventory', true) .then(output) .then(() => getStat('shipping')) .then(output) .catch((err) => console.error('Caught:', err.message));

getStat('inventory', true) rejects. Neither of the two .then() calls in between ever run. Nothing was written to make them skip themselves, they just don't have an error handler, so the rejection passes straight through both of them and lands on the first .catch() it finds.

That's not a special case you have to code for. It's just how a chain behaves by default: a step with no error handler quietly forwards whatever rejection it received to the next step.

.catch() isn't a different mechanism. It's a .then() with nothing passed for the first argument, only the second one, the error handler.

The Mistake That Undoes the Whole Point

Here's the trap. It's easy to write a chain that still technically works, but nests .then() calls inside each other instead of returning the next promise back up to the main chain.

✕
Nested (promise hell)
JavaScript
getStat('inventory').then((text) => { output(text); getStat('shipping').then((text) => { output(text); }); });
✓
Chained
JavaScript
getStat('inventory') .then(output) .then(() => getStat('shipping')) .then(output);

Both versions produce the same output. The left one still works. But it's back to the exact shape callback hell had: each step buried one level deeper inside the last, harder to scan with every step you add.

The fix is the same rule from the last post: return the next promise back to the outer chain instead of calling .then() on it from inside another handler. Keep the chain vertical, not sideways.

This is worth naming because it's an easy trap to fall into, especially early on, since the nested version looks almost identical to the flat one at a glance. If a promise chain in your own code is drifting sideways instead of staying vertical, that's the signal to return the inner promise up a level instead of chaining onto it where it sits.