Callback Hell is very scary term. It intimidates inexperienced developers, who desperately try to avoid this situation in their code, but fail quite often. On the other end of the spectrum, Callback Hell gives conference speakers and frameworks designers lots of material to discuss and work with.
In this post I’ll explain what Callback Hell is and how to fix it in your code.
Callback Hell
In general, you can get “callback hell” when you compose multiple asynchronous flows that use so-called callback methods to pass execution results back to their callers. Such a composition can be difficult to understand, thus hurting the maintainability of your codebase.
For the sake of demonstration, suppose that you need to fetch data from internet, perform some time-consuming processing on it and then store the results into local database. Furthermore, let’s assume that you already have three standalone components that take care of each of the aforementioned steps.
In this situation, you could implement the required flow in the following way:
public void executeFlow(final FlowCallback flowCallback) { mNetworkEndpoint.executeRequest(networkResult -> { if (!networkResult.isSuccess()) { flowCallback.onFlowCompleted(FlowCompletionResult.NETWORK_ERROR); return; } mDataProcessor.processData(networkResult, dataProcessingResult -> { if (!dataProcessingResult.isSuccess()) { flowCallback.onFlowCompleted(FlowCompletionResult.PROCESSING_FAILED); return; } mDao.storeData(dataProcessingResult, () -> { flowCallback.onFlowCompleted(FlowCompletionResult.SUCCESS) }); }); }); }
This code is quite difficult to read and reason about. Furthermore, maintenance and future additions to this beast are likely to be risky. Just imagine that one day you’ll need to add additional three steps to this flow. That’s your classical Callback Hell.
By the way, the above example is simple and small. Callback Hell can get much more problematic than that. Click this link to see really nasty real-world callback hell (thanks to Gabor Varadi for the link).
In my experience, even if code with Callback Hell works initially, it’ll come to bite you in the ass in the long term. Therefore, you need to do something about that.
Callback Hell Root Cause
If you think about it, it’s very simple to call a method and return the result of its execution synchronously. If you do that, there is no need for callbacks and no risk of descending into callback hell. Why would anyone even use callbacks then?
Well, the problem arises once you need to execute time-consuming tasks in GUI frameworks.
For example, if you’d write Java backend app, your life would be relatively simple (at least in the context of callbacks) because backend frameworks execute each server request on a dedicated thread. Therefore, you’d be able to execute all the operations synchronously and then simply return the result to the caller over HTTP. No need for callbacks at all.
However, if you’d write Android app, it wouldn’t be that simple anymore.
In Android, all the code related to GUI should execute on a special UI thread. This thread shouldn’t be blocked for any reason. Therefore, you can’t execute network requests and other time-consuming tasks on UI thread and need to offload them to so-called background threads. Once you do that, you need to use callbacks to deliver results back to UI thread.
So, callbacks are necessary evil in GUI frameworks required to support concurrency. Their usage allows for asynchronous notifications upon completion of concurrent flows. Alright, but how do we get from here to callback hell?
Callback hell is just a composition of several tasks that use asynchronous notifications. Please note that these tasks can be concurrent, but they don’t have to. The emergence of callback hell isn’t really predicated on the threading implementation details of the tasks, just on the asynchronous nature of their API.
Callback Hell Resolution
So, callbacks are inevitable in one form or another if we want to have asynchronous notifications. That’s true, but it doesn’t mean that callback hell is inevitable.
In the above example, the method executeFlow()
must receive callback as an argument because we want to be able to invoke it on UI thread and be notified about the results later. But do we really need to call its internal collaborators on UI thread? The answer is no!
Let’s assume that all objects which constitute this flow handle multithreading internally. In other words, they offload execution to background threads and return to UI thread before invoking their callbacks. In total, during this flow, there will be three transitions from UI thread to some background thread and another three transitions back to UI thread. Feels a bit too much, don’t you think?
Instead, let’s reduce this flow to just one offload to a background thread and one return to UI thread:
public void executeFlow(final FlowCallback flowCallback) { mBackgroundThreadPoster.post(() -> executeFlowSync(flowCallback)); } @WorkerThread private void executeFlowSync(FlowCallback flowCallback) { NetworkResult networkResult = mNetworkEndpoint.executeRequestSync(); if (!networkResult.isSuccess()) { notifyFlowCompleted(flowCallback, FlowCompletionResult.NETWORK_ERROR); return; } ProcessingResult processingResult = mDataProcessor.processDataSync(networkResult); if (!processingResult.isSuccess()) { notifyFlowCompleted(flowCallback, FlowCompletionResult.PROCESSING_FAILED); return; } mDao.storeDataSync(processingResult); notifyFlowCompleted(flowCallback, FlowCompletionResult.SUCCESS); } private void notifyFlowCompleted(final FlowCallback flowCallback, final FlowCompletionResult result) { mUiThreadPoster.post(() -> flowCallback.onFlowCompleted(result)); }
I use ThreadPoster library for concurrency in the above implementation, but you could use a bare Thread class and UI Handler in exactly the same manner.
The first thing you might notice is that the code became longer. I don’t mind that. In fact, I could spare several lines of code if I wouldn’t extract additional methods, but I’m always willing to write more code to get better design and readability. In this case, the flow itself is encapsulated in executeFlowSync()
method, which contains synchronous, step by step, simple definition of what needs to be done. Two other methods handle concurrency.
The upsides of this approach should become clear if you imagine that one day you’ll need to add three more steps to this flow. With this implementation, it would be simple task confined to executeFlowSync()
method and there would be no callbacks involved at all. In addition, since the collaborator objects are invoked synchronously, they don’t need to handle multithreading and support asynchronous notifications, so their internal implementation can be simplified a lot.
Eventually, you’ll realize that this entire flow with all its dependencies can be encapsulated in a standalone object. Then the clients won’t know anything about its implementation details and it’ll be ready for future reuse. That’s basically the idea behind “use case” classes (also known as “interactors”) which are part of Clean Architecture school of thought.
Resolving Callback Hell Using Frameworks
Many developers “smuggle frameworks” into the picture as part of callback hell discussion. The argument goes like this: “Framework X fixes callback hell with zero effort (or is immune to callback hell to begin with), so just use it and you’re done”. With all due respect, I, personally, think that if you don’t address callback hell with better design and encapsulation, then your solution won’t be optimal.
RxJava, for example, allows you to convert an ugly chain of callbacks into a bit less ugly chain of operators. This is just a band aid. The resulting code might be shorter and less indented, but hardly any better. Long chains of Rx operators without proper abstractions are almost as harmful to your project as long chains of nested callbacks.
Similar argument can be made about Kotlin Coroutines. Coroutines-based code looks quasi-sequential, so it feels like the silver bullet solution to callback hell. In some sense, it is. However, that’s still an asynchronous code and the nuances of Coroutines framework itself add much additional complexity.
Therefore, while frameworks can mitigate the problem of callback hell to some degree, they aren’t necessary and they aren’t sufficient to address that problem properly on their own.
Conclusion
Callback Hell is a very popular topic among web-frontend developers, but it has a strong grip in Android community as well. It’s a real problem that can cause a real harm to maintainability of your codebase.
Luckily, preventing, or fixing callback hell is possible. The solution I use basically amounts to establishing proper multithreading boundaries in the app and avoiding unneeded asynchronous notifications. It’s not simple, but not a rocket science either. For example, I use this technique when I need to compose “use case” classes as described in this article.
While Callback Hell is quite often used to market specific frameworks and language features, these aren’t necessary and aren’t sufficient to address this problem on their own. Therefore, I recommend paying attention to the concepts discussed in this article even if you use the latest and the greatest framework out there.
As usual, thanks for reading. Leave your comments and questions below.
Hi Vasiliy,
Cool article! Overall reading your posts make me glad there’s someone explaining popular things while digging into Android framework so people (at least me) can gain more knowledge about ecosystem they’re working with. 🙂
One case is over my head when dealing with callback hell:
What’s your approach when dealing with two or more hot data sources (e.g. websockets, BT, accelerometer) and you need to combine them into singular data source? I know RxJava and (probably) Coroutines Flow handle that case with ease but when there’s no option to use them (due to numerous reasons) we steal need to keep our architecture clean.
Hi Arek,
That’s not exactly related to callback hell and the answer depends on what exactly you mean by “combine”. Let’s say that we have two asynchronous, independent data sources which implement Observer pattern and emit events to subscribers. What strategy should be used to “combine” these events and produce the resulting data source?
Please, think about something complex and let me know. I think I’ll dedicate the next post to this feature 😉
Hello,
I’m more and more using Elegant Objects principles : https://www.elegantobjects.org/
I’m currently writing an EO Android library I will publish soon : https://github.com/RoRoche/elegant-android
Here is an example of use : https://github.com/RoRoche/elegant-android/blob/master/app/src/main/java/fr/guddy/elegantandroid/screens/repo/RepoActivity.kt
Hope this could be useful.
Regards,
Romain
Hey Romain,
Never heard of “Elegant Objects”, so thanks for the links.
I just skimmed the first one quickly. I like some of the principles, but find other ones too restrictive and dogmatic. For example, looks like this paradigm promotes what I’d consider to be an abuse of interfaces. But if it works for you, then it’s cool. There is always more than one way to skin a cat )
You’re right, it’s widely dogmatic and polemic ?
It’s a way I try for fun because I first doubted about it, but now I find some useful ideas.
Thanks for the post and your reply ?
You can do it with promises as well. For example:
[code language=”java”]
/**
* Creates a new request. It would be easier if mNetworkEndpoint also had promises, but callback works as well
*/
FlowPromise sendNetworkRequest() {
return FlowPromise(flowCallback ->
mNetworkEndpoint.executeRequest(networkResult -> {
if (networkResult.isSuccess()) {
flowCallback.resolve(networkResult)
} else {
flowCallback.reject(FlowCompletionResult.NETWORK_ERROR);
}
})
)
}
[/code]
[code language=”java”]
/**
* This way you can compose promises. Use andThen to call the next function.
* storeData and processData can be reused in other flows
*/
public FlowPromise executeFlow() {
return sendNetworkRequest()
.andThen(result -> mDataProcessor.processData(networkResult))
.andThen(dataProcessingResult -> mDao.storeData(dataProcessingResult))
// you can recover from errors here or in every function, whatever suits more
.recover(e -> /** Add error handling here*/)
// changing threads will be a oneliner and you only need to do it once
.changeThead(mainLooper)
}
[/code]
Hey Yuriy,
Thanks for providing an example. Promises are still callbacks-based solution (each Promise executes a callback), but it indeed looks better than Callback Hell. I would treat this as another framework – it can mitigate the problem to some degree, but shouldn’t be used exclusively. For example, even if you use Promise instead of top-level callback, I wouldn’t recommend using them to implement inner functionality. Synchronous method calls are the best solution there.
Hi,
You never say anything about LiveData when you make analysis about multithreading frameworks.
Isn’t that a better alternative than RxJava or coroutines?
Since is promoted by google it seems to me that is becoming the most popular for handling network/db operations.
Thx
Hi Adrian,
LiveData isn’t multithreading framework (or a crippled one, depending on your perspective). It’s just an implementation of Observer design pattern that always notifies the observers on UI thread.
I don’t use it in my projects, but I guess it can be incorporated here and there if you like it. That said, I reviewed Google’s Android Dev Summit app recently, and, boy, the combo ViewModel+LiveData is a mess there. So, be careful not to abuse it.
Hello,
Thanks for the another good article and I have some questions,
1. How can you handle the cancellation in your implementation?
2. What if the network or database library doesn’t support synchronous execution?
3. What if I need to call another network request concurrently and combine them to save data?
Cheers
Hello Jun,
These questions are outside of the scope of this article and answering each of them properly would require a post on its own. However, I don’t want to leave you empty handed, so here are my high-level thoughts:
“…because backend frameworks execute each server request on a dedicated thread.”
What about reactive backend frameworks like Spring WebFlux, Quarkus, Micronaut or Vert.x?
I don’t know anything about these. However, I suspect that they use “thread per request” model too.