Two Popular Non-Fixes for Callback Hell

Why split success and error callbacks, and Node's error-first callback convention, both feel like a fix for callback hell but leave the exact same trust problem in place.

September 5, 20263 min read8 / 17

Here's the actual idea this post teaches, in one sentence: splitting callbacks into success/error, and Node's error-first convention, both rearrange how many callbacks you pass without ever answering the real question, whether the thing you called will behave, so the trust problem survives both "fixes" completely intact.

Once the trust problem is visible, two patterns show up almost immediately trying to fix it. Neither one actually does. Both just move the same unanswered question somewhere else.

Attempt One: A Separate Callback for Errors

The reasoning sounds solid: if an error might happen, give it its own callback so it can't get missed inside the success path.

JavaScript
function fakeFetchOrder(orderId, onSuccess, onError) { setTimeout(() => onSuccess({ orderId, total: 84 }), 300); } fakeFetchOrder( 'order-1', (order) => console.log(order), (err) => console.error(err) );

This looks like progress, right up until you ask a question this code has no answer for. What happens if fakeFetchOrder calls neither callback? What happens if it calls both?

Nothing here handles either case. Adding a second callback didn't remove the trust problem, it just gave the trust problem a second door to sneak in through.

Attempt Two: One Callback, Error-First

Node's answer is to go back to a single callback, and reserve its first argument for an error if there is one.

JavaScript
function fakeFetchOrderErrFirst(orderId, cb) { setTimeout(() => cb(null, { orderId, total: 84 }), 300); } fakeFetchOrderErrFirst('order-1', function (err, order) { if (err) { console.error(err); return; } console.log(order); });

This is why nearly every Node callback you've ever written starts with if (err) { ... }. It feels tidier: one callback, one convention, everyone follows the same shape.

The same two questions from before are still sitting there, completely unanswered. What if this fires twice? What if err and order are both set on the same call? Nothing about error-first callbacks rules either of those out.

split callbackstwo doors
→
error-first callbackone door, same lock
→
trust problemstill unsolved

What Neither Attempt Touches

Both patterns are trying to fix inversion of control from the outside, by rearranging how many callbacks you pass and in what shape. Neither one changes who's actually in control. The utility you called still decides when, how, and how many times to call you back. You're just guessing at its behavior with a slightly different-shaped guess.

Meanwhile, the other problem, the one about nesting, never went anywhere either. A real temporal dependency, one step that genuinely can't start before another finishes, still only has one tool available in plain callbacks:

JavaScript
function fakeAsync(value, cb) { setTimeout(() => cb(value), 300); } function getBasePrice(sku, cb) { fakeAsync(120, cb); } function getDiscount(base, cb) { fakeAsync(base * 0.9, cb); } function getFinalTotal(discounted, cb) { fakeAsync(discounted * 1.08, cb); } getBasePrice('sofa-142', function (base) { getDiscount(base, function (discounted) { getFinalTotal(discounted, function (total) { console.log(`Final total: $${total.toFixed(2)}`); }); }); });

Three genuine dependencies, three levels of nesting, no split callback or error-first convention anywhere in sight to blame. Neither non-fix was ever aimed at this problem in the first place.

That's the real reason both attempts fall short. They treat callback hell as a naming or argument-order problem, when the actual problems are trust and nesting.

Fixing those properly means changing the tool, not rearranging the same one. A small tool called a thunk is the first step toward that, next.