Callback hell is real. Developers often see callbacks as pure evil, even to the point of avoiding them. JavaScriptʼs flexibility doesn’t help at all with this. But it’s not necessary to avoid callbacks. The good news is that there are simple steps to get saved from callback hell.
Eliminating callbacks in your code is like amputating a good leg. A callback function is one of the pillars of JavaScript and one of its good parts. When you replace callbacks, you’re often just swapping problems.
Some say callbacks are ugly warts and are the reason to study better languages. Well, are callbacks that ugly?
Wielding callbacks in JavaScript has its own set of rewards. There’s no reason to avoid JavaScript because callbacks can turn into ugly warts.We can just make sure that doesn’t happen.
Letʼs dive into what sound programming has to offer with callbacks. Our preference is to stick to SOLID principles and see where this takes us.
Key Takeaways
What Is Callback Hell?
You may be wondering what a callback is and why you should care. In JavaScript, a callback is a function that acts as a delegate. The delegate executes at an arbitrary moment in the future. In JavaScript, the delegation happens when the receiving function calls the callback. The receiving function may do so at any arbitrary point in its execution.
In short, a callback is a function passed in as an argument to another function. There’s no immediate execution, since the receiving function decides when to call it. The following code sample illustrates:
function receiver(fn) {
return fn();
}
function callback() {
return 'foobar';
}
var callbackResponse = receiver(callback);
// callbackResponse == 'foobar'
If you’ve ever written an Ajax request, you’ve encountered callback functions. Asynchronous code uses this approach, since there’s no guarantee when the callback will execute.
The problem with callbacks stems from having async code that depends on another callback. We can use setTimeout
to simulate async calls with callback functions.
Feel free to follow along. The repo is available on GitHub, and most code snippets will come from there so you can play along.
Behold, the pyramid of doom!
setTimeout(function (name) {
var catList = name + ',';
setTimeout(function (name) {
catList += name + ',';
setTimeout(function (name) {
catList += name + ',';
setTimeout(function (name) {
catList += name + ',';
setTimeout(function (name) {
catList += name;
console.log(catList);
}, 1, 'Lion');
}, 1, 'Snow Leopard');
}, 1, 'Lynx');
}, 1, 'Jaguar');
}, 1, 'Panther');
Looking at the code above, setTimeout
gets a callback function that executes after one millisecond. The last parameter just feeds the callback with data. This is like an Ajax call, except that the return name
parameter would come from the server.
There’s a good overview of the setTimeout function on SitePoint.
In our code, we’re gathering a list of ferocious cats through asynchronous code. Each callback gives us a single cat name, and we append that to the list. What we’re attempting to achieve sounds reasonable. But given the flexibility of JavaScript functions, this is a nightmare.
Anonymous Functions
Notice the use of anonymous functions in that previous example. Anonymous functions are unnamed function expressions that get assigned to a variable or passed as an argument to other functions.
Using anonymous functions in your code is not recommended by some programming standards. It’s better to name them, so use function getCat(name){}
instead of function (name){}
. Putting names in functions adds clarity to your programs. These anonymous functions are easy to type, but they send you barreling down on a highway to hell. When you find yourself going down this winding road of indentations, it’s best to stop and rethink.
One naive approach to breaking this mess of callbacks is for us to use function declarations:
setTimeout(getPanther, 1, 'Panther');
var catList = '';
function getPanther(name) {
catList = name + ',';
setTimeout(getJaguar, 1, 'Jaguar');
}
function getJaguar(name) {
catList += name + ',';
setTimeout(getLynx, 1, 'Lynx');
}
function getLynx(name) {
catList += name + ',';
setTimeout(getSnowLeopard, 1, 'Snow Leopard');
}
function getSnowLeopard(name) {
catList += name + ',';
setTimeout(getLion, 1, 'Lion');
}
function getLion(name) {
catList += name;
console.log(catList);
}
You won’t find this snippet in the repo, but the incremental improvement can be found on this commit.
Each function gets its own declaration. One upside is that we no longer get the gruesome pyramid. Each function gets isolated and laser focused on its own specific task. Each function now has one reason to change, so it’s a step in the right direction. Note that getPanther()
, for example, gets assigned to the parameter. JavaScript doesn’t care how we create callbacks. But what are the downsides?
For a full breakdown of the differences, see this SitePoint article on Function Expressions vs Function Declarations.
A downside, though, is that each function declaration no longer gets scoped inside the callback. Instead of using callbacks as a closure, each function now gets glued to the outer scope. Hence why catList
gets declared in the outer scope, as this grants the callbacks access to the list. At times, clobbering the global scope isn’t an ideal solution. There’s also code duplication, as it appends a cat to the list and calls the next callback.
These are code smells inherited from callback hell. Sometimes, striving to enter callback freedom needs perseverance and attention to detail. It may start to feel as if the disease is better than the cure. Is there a way to code this better?
Dependency Inversion
The dependency inversion principle says we should code to abstractions, not to implementation details. At the core, we take a large problem and break it into little dependencies. These dependencies become independent to where implementation details are irrelevant.
This SOLID principle states:
When following this principle, the conventional dependency relationships established from high-level, policy-setting modules to low-level, dependency modules are reversed, thus rendering high-level modules independent of the low-level module implementation details.
So what does this blob of text mean? The good news is that, by assigning a callback to a parameter, we’re already doing this! At least in part, to get decoupled, think of callbacks as dependencies. This dependency becomes a contract. From this point forward, we’re doing SOLID programming.
One way to gain callback freedom is to create a contract:
fn(catList);
This defines what we plan to do with the callback. It needs to keep track of a single parameter — that is, our list of ferocious cats.
This dependency can now get fed through a parameter:
function buildFerociousCats(list, returnValue, fn) {
setTimeout(function asyncCall(data) {
var catList = list === '' ? data : list + ',' + data;
fn(catList);
}, 1, returnValue);
}
Note that the function expression asyncCall
gets scoped to the closure buildFerociousCats
. This technique is powerful when coupled with callbacks in async programming. The contract executes asynchronously and gains the data
it needs, all with sound programming. The contract gains the freedom it needs as it gets decoupled from the implementation. Beautiful code uses JavaScriptʼs flexibility to its own advantage.
The rest of what needs to happen becomes self-evident. We can do this:
buildFerociousCats('', 'Panther', getJaguar);
function getJaguar(list) {
buildFerociousCats(list, 'Jaguar', getLynx);
}
function getLynx(list) {
buildFerociousCats(list, 'Lynx', getSnowLeopard);
}
function getSnowLeopard(list) {
buildFerociousCats(list, 'Snow Leopard', getLion);
}
function getLion(list) {
buildFerociousCats(list, 'Lion', printList);
}
function printList(list) {
console.log(list);
}
There’s no code duplication here. The callback now keeps track of its own state without global variables. A callback such as getLion
can get chained with anything that follows the contract — that is, any abstraction that takes a list of ferocious cats as a parameter. This sample code is available on GitHub.
Polymorphic Callbacks
Okay, letʼs get a little crazy. What if we wanted to change the behavior from creating a comma-separated list to a pipe-delimited one? One problem we can envisage is that buildFerociousCats
has been glued to an implementation detail. Note the use of list + ',' + data
to do this.
The simple answer is polymorphic behavior with callbacks. The principle remains: treat callbacks like a contract and make the implementation irrelevant. Once the callback elevates to an abstraction, the specific details can change at will.
Polymorphism opens up new ways of code reuse in JavaScript. Think of a polymorphic callback as a way to define a strict contract, while allowing enough freedom that implementation details no longer matter. Note that we’re still talking about dependency inversion. A polymorphic callback is just a fancy name that points out one way to take this idea further.
Letʼs define the contract. We can use the list
and data
parameters in this contract:
cat.delimiter(cat.list, data);
Then we can make a few tweaks to buildFerociousCats
:
function buildFerociousCats(cat, returnValue, next) {
setTimeout(function asyncCall(data) {
var catList = cat.delimiter(cat.list, data);
next({ list: catList, delimiter: cat.delimiter });
}, 1, returnValue);
}
The JavaScript object cat
now encapsulates the list
data and delimiter
function. The next
callback chains async callbacks — formerly called fn
. Notice that there’s freedom to group parameters at will with a JavaScript object. The cat
object expects two specific keys — list
and delimiter
. This JavaScript object is now part of the contract. The rest of the code remains the same.
To fire this up, we can do this:
buildFerociousCats({ list: '', delimiter: commaDelimiter }, 'Panther', getJaguar);
buildFerociousCats({ list: '', delimiter: pipeDelimiter }, 'Panther', getJaguar);
The callbacks get swapped. As long as contracts get fulfilled, implementation details are irrelevant. We can change the behavior with ease. The callback, which is now a dependency, gets inverted into a high-level contract. This idea takes what we already know about callbacks and raises it to a new level. Reducing callbacks into contracts lifts up abstractions and decouples software modules.
What’s so radical here is that unit tests naturally flow from independent modules. The delimiter
contract is a pure function. This means that, given a number of inputs, we get the same output every single time. This level of testability adds confidence that the solution will work. After all, modular independence grants the right to self-assess.
An effective unit test around the pipe delimiter might look something like this:
describe('A pipe delimiter', function () {
it('adds a pipe in the list', function () {
var list = pipeDelimiter('Cat', 'Cat');
assert.equal(list, 'Cat|Cat');
});
});
Iʼll let you imagine what the implementation details look like. Feel free to check out the commit on GitHub.
Promises
A promise is simply a wrapper around the callback mechanism, and allows for a thenable to continue the flow of execution. This makes the code more reusable because you can return a promise and chain the promise.
Let’s build on top of the polymorphic callback and wrap this around a promise. Tweak the buildFerociousCats
function and make it return a promise:
function buildFerociousCats(cat, returnValue, next) {
return new Promise((resolve) => { //wrapper and return Promise
setTimeout(function asyncCall(data) {
var catList = cat.delimiter(cat.list, data);
resolve(next({ list: catList, delimiter: cat.delimiter }));
}, 1, returnValue);
});
}
Note the use of resolve
: instead of using the callback directly, this is what resolves the promise. The consuming code can apply a then
to continue the flow of execution.
Because we’re now returning a promise, the code must keep track of the promise in the callback execution.
Let’s update the callback functions to return the promise:
function getJaguar(cat) {
return buildFerociousCats(cat, 'Jaguar', getLynx); // Promise
}
function getLynx(cat) {
return buildFerociousCats(cat, 'Lynx', getSnowLeopard);
}
function getSnowLeopard(cat) {
return buildFerociousCats(cat, 'Snow Leopard', getLion);
}
function getLion(cat) {
return buildFerociousCats(cat, 'Lion', printList);
}
function printList(cat) {
console.log(cat.list); // no Promise
}
The very last callback doesn’t chain promises, because it doesn’t have a promise to return. Keeping track of the promises is important for guaranteeing a continuation at the end. Via analogy, when we make a promise, the best way to keep the promise is by remembering that we once made that promise.
Now let’s update the main call with a thenable function call:
buildFerociousCats({ list: '', delimiter: commaDelimiter }, 'Panther', getJaguar)
.then(() => console.log('DONE')); // outputs last
If we run the code, we’ll see that “DONE” prints at the end. If we forget to return a promise somewhere in the flow, “DONE” will appear out of order, because it loses track of the original promise made.
Feel free to check out the commit for promises on GitHub.
Async/Await
Lastly, we can think of async/await as syntactic sugar around a promise. To JavaScript, async/await is actually a promise, but to the programmer it reads more like synchronous code.
From the code we have so far, let’s get rid of the then
and wrap the call around async/await:
async function run() {
await buildFerociousCats({ list: '', delimiter: pipeDelimiter }, 'Panther', getJaguar)
console.log('DONE');
}
run().then(() => console.log('DONE DONE')); // now really done
The output “DONE” executes right after the await
, because it works much like synchronous code. As long as the call into buildFerociousCats
returns a promise, we can await the call. The async
marks the function as one returning a promise, so it’s still possible to chain the call into run
with yet another then
. As long as what we call into returns a promise, we can chain promises indefinitely.
You can check this out in the async/await commit on GitHub.
Keep in mind that all this asynchronous code runs in the context of a single thread. A JavaScript callback fits well within this single-threaded paradigm, because the callback gets queued in such a way that it doesn’t block further execution. This makes it easier for the JavaScript engine to keep track of callbacks, and pick up the callback right away without having to deal with synchronizing multiple threads.
Conclusion
Mastering callbacks in JavaScript is understanding all the minutiae. I hope you see the subtle variations in JavaScript functions. A callback function becomes misunderstood when we lack the fundamentals. Once JavaScript functions are clear, SOLID principles soon follow. It requires a strong grasp of the fundamentals to get a shot at SOLID programming. The inherent flexibility in the language places the burden of responsibility on the programmer.
What I love the most is that JavaScript empowers good programming. A good grasp of all the minutiae and fundamentals will take us far in any language. This approach to callback functions is super important in vanilla JavaScript. By necessity, all the nooks and crannies will take our skills to the next level.
FAQs About Callback Hell
Callback hell, also known as the pyramid of doom, is a situation in asynchronous JavaScript programming where multiple nested callbacks make the code difficult to read and maintain. It often occurs when dealing with multiple asynchronous operations or nested callbacks within callbacks.
Callback hell is caused by the asynchronous and event-driven nature of JavaScript, especially when handling multiple asynchronous tasks or operations sequentially. This often results in deeply nested callback functions.
Callback hell can be avoided or mitigated by using techniques such as Promises, async/await, or libraries like async.js
. These approaches help improve code readability and maintainability by providing a more structured way to handle asynchronous operations.
Promises are objects in JavaScript used for asynchronous operations. They represent the eventual completion or failure of an asynchronous task. Promises help mitigate callback hell by providing a cleaner and more readable syntax for handling asynchronous code, allowing for a more sequential and less nested structure.
The async/await
syntax is a more recent addition to JavaScript that simplifies asynchronous code. By using async
functions and the await
keyword, developers can write asynchronous code in a more synchronous style, making it easier to read and understand, and reducing the likelihood of callback hell.
Yes, some best practices include modularizing code, using named functions instead of anonymous functions, and adopting a modular and functional programming approach. Breaking down complex tasks into smaller, more manageable functions can also help reduce callback hell.
Husband, father, and software engineer from Houston, Texas. Passionate about JavaScript and cyber-ing all the things.