Published on June 25, 2025

A callback is essentially a function passed into another function to be invoked later, typically when an asynchronous task completes or an event occurs. In Kotlin (as in many languages), callbacks enable non-blocking, event-driven code. For example, instead of waiting synchronously for a network request or a long computation (which on Android can freeze the UI and even trigger an ANR, App Not Responding, error), we register a callback that will be called when the work is done. The receiving function “calls back” into the caller’s code at the right time. This pattern is fundamental in asynchronous programming — callbacks act as the basic primitive for chaining operations without blocking.
In practice, a callback often takes the form of a function type or a single-method interface. Kotlin has first-class support for function types and lambdas, so a callback is typically a lambda parameter. For example, you might define:
fun loadData(callback: (String) -> Unit) {
// Simulate loading data (e.g. from network or disk)
Thread {
Thread.sleep(1000) // Pretend this is a long-running I/O
callback("Result from async operation") // Invoke the callback when done
}.start()
}
fun main() {
println("Before call")
loadData { data ->
println("Callback received: $data")
}
println("After call")
}
Here, loadData takes a callback: (String) -> Unit parameter. It spawns a background thread (so as not to block the main thread) that sleeps for 1 second, then invokes the callback with a result. In main(), we call loadData and supply a lambda. When loadData finishes its work, it “calls back” by running our lambda. The console output will show “Before call”, then “After call”, and finally (after 1 second) “Callback received: Result from async operation”. This demonstrates how the code continues running without waiting, and the callback handles the result later. Such callbacks are ubiquitous in Android – for example, view click listeners (button.setOnClickListener { … }) or network request listeners use the same idea: you pass code to be run when the action occurs.
Kotlin’s trailing-lambda syntax makes these patterns very concise (if the last parameter of a function is a function, you can put the lambda outside the parentheses).
Kotlin treats functions as first-class citizens, so you can pass functions (including lambdas) to other functions. A function that accepts a function argument (or returns one) is called a higher-order function. For callbacks, we usually write something like:
fun performOperation(x: Int, y: Int, callback: (Int) -> Unit) {
// Do some work with x, y
val result = x + y // Example operation
callback(result) // Invoke the callback with the result
}
fun main() {
performOperation(5, 7) { sum ->
println("Got sum = $sum")
}
}
This code defines performOperation with a function-type parameter callback: (Int) -> Unit. We call it with two integers and a lambda. When performOperation finishes computing, it invokes callback(sum). Kotlin allows trailing lambdas, so the lambda argument can go outside the parentheses as shown. If the lambda is the only argument, you can even omit the parentheses entirely.
Callbacks can also have multiple parameters or generic types. For example, a callback might pass an error along with a result:
fun fetchUser(userId: String, callback: (result: String?, error: Exception?) -> Unit) {
// (simulate success or failure)
Thread {
Thread.sleep(500)
callback("UserDataFor-$userId", null) // on success
// or: callback(null, Exception("Network error"))
}.start()
}
Here the callback has two parameters (String?, Exception?), allowing it to receive either a result or an error. The caller would check which is non-null. This pattern is common (e.g. many Android APIs use onSuccess and onFailure callbacks). Kotlin’s lambdas can handle multiple inputs easily: the caller might write fetchUser("abc") { data, err -> if (err!=null) println("Error") else println("Data is $data") }.
Underneath, function-type callbacks are just a more concise alternative to the Java-style approach of defining an interface with a single method. For example, you could define:
interface DataCallback {
fun onComplete(data: String)
}
fun load(dataCallback: DataCallback) { ... }
and invoke dataCallback.onComplete("..."). Thanks to Kotlin’s SAM-conversion (for Java interfaces), you could even pass a lambda where an interface instance is expected (e.g. load(DataCallback { result -> ... })). However, for pure Kotlin, using function types is more idiomatic and concise.
In Android development, callbacks are everywhere. Almost any event or asynchronous operation is handled by a callback function or interface. For example:
View event listeners: Android Views support things like setOnClickListener(listener). In Kotlin you typically write button.setOnClickListener { /* code */ }. Under the hood, OnClickListener is a Java interface with a single onClick() method. Kotlin lets us supply a lambda since it’s a SAM interface.
Async operations: Many Android APIs (or libraries like Retrofit for network calls) use callbacks. For instance, Retrofit in its default mode gives you an enqueue(Callback) method; you override onResponse() and onFailure() to handle success or error. Similarly, older AsyncTask classes had an onPostExecute(result) callback. Each of these is conceptually the same: you give the system a function (or interface) to call later when work is done.
Listeners and callbacks: Components like SensorManager, LocationManager, MediaPlayer, etc., all use listeners (callbacks) to report data or status. For example, LocationManager.requestLocationUpdates takes a LocationListener callback that will be invoked whenever the device’s location changes.
In all these cases, your code initiates some operation and immediately continues; when the operation finishes (or an event happens), the Android framework “calls back” into your listener code. This avoids blocking the UI thread.
When multiple asynchronous steps depend on each other, callbacks can become deeply nested and hard to read. This situation is often called “callback hell” or the “pyramid of doom.” For example, suppose you need to perform three steps in sequence, each requiring a callback to the next:
fun step1(callback: (String) -> Unit) {
Thread { Thread.sleep(300); callback("Result1") }.start()
}
fun step2(input: String, callback: (String) -> Unit) {
Thread { Thread.sleep(300); callback("Result2 from $input") }.start()
}
fun step3(input: String, callback: (String) -> Unit) {
Thread { Thread.sleep(300); callback("Final3 from $input") }.start()
}
fun main() {
step1 { res1 ->
println(res1)
step2(res1) { res2 ->
println(res2)
step3(res2) { res3 ->
println(res3)
}
}
}
}
Here, each step calls the next inside its callback. As more steps or branches are added, the code forms a deep right-leaning indentation. This nested style becomes very difficult to read, maintain, and especially to handle errors or exceptions at each level. Debugging and error handling are onerous because each level needs its own try/catch or failure check.
Source: https://www.techyourchance.com/wp-content/uploads/2020/04/callback_hell.jpeg
This is exactly callback hell: when there are too many nested callbacks, the code becomes difficult to read, handle errors, and debug. In Android UI code, this often led to deeply indented code or anonymous inner classes inside anonymous inner classes.
There are several strategies to mitigate callback hell:
Refactor into named functions: Instead of anonymous lambdas nested inline, you can extract logic into separate functions. For example, have onStep1Result(res1) call step2, and so on. This flattens indentation but can make code structure more verbose or fragmented.
Use Promises/Futures: In some contexts, you can use constructs like Java’s CompletableFuture or third-party promise libraries. These allow chaining operations and centralized error handling (e.g. using future.thenCompose {...} or CompletableFuture.exceptionally{...}). Kotlin on the JVM can interoperate with CompletableFuture, though it’s not as idiomatic as Kotlin-native solutions.
Use Reactive libraries (RxJava/RxKotlin): Before coroutines were widespread, many Android projects used RxJava. Observables/Flowables allow chaining (flatMap, map, etc.) and provide declarative error handling. Rx chains can flatten callback nesting, but Rx has its own learning curve and complexity. In modern Kotlin, Flow (below) covers many of the same use cases in a more lightweight way.
Structured concurrency (Kotlin Coroutines): The most popular modern solution in Kotlin/Android is coroutines. Coroutines let you write asynchronous code sequentially. With suspend functions and coroutine builders, you essentially replace the nested callbacks with straight-line code. In coroutines you can perform asynchronous calls with delay or other suspendable functions, and simply await results one after another. Error handling also becomes straightforward with try/catch. As one guide notes, *“By adopting coroutines, you eliminate the need for cumbersome callbacks, making your asynchronous code more linear and easier to comprehend” *[medium.com]. Coroutines are a form of cooperative multitasking where the compiler and runtime handle the callback plumbing under the hood.
CallbackFlow / Channels / Flows: For cases where you have a stream of callback events (e.g. multiple values or repeated events), Kotlin’s coroutines provide Flow or Channel constructs. In particular, callbackFlow lets you adapt listener callbacks into a Flow of values. This way, instead of manually calling a callback for each event, you emit() values into a flow and collect them in a coroutine (see Kotlin Flow below).
In summary, the key is to reduce anonymous nesting and use higher-level abstractions. Kotlin’s language features (lambdas, extension functions, etc.) already help reduce syntactic verbosity, but coroutines and flows address the deeper structural issues of callback hell.
Kotlin coroutines are now the recommended way to handle asynchronous tasks on Android (and Kotlin in general). A coroutine is a lightweight thread-like construct that can suspend its execution at certain points without blocking the underlying thread. You write code in a sequential style, but when a suspend function (like delay, or a long-running I/O) is hit, the coroutine yields and resumes later. Under the hood, callbacks are used to resume the suspended coroutine when the asynchronous work completes.
For example, we can rewrite the nested steps above using suspend functions:
import kotlinx.coroutines.*
suspend fun step1(): String {
delay(300) // pretend asynchronous work
return "Result1"
}
suspend fun step2(input: String): String {
delay(300)
return "Result2 from $input"
}
suspend fun step3(input: String): String {
delay(300)
return "Final3 from $input"
}
fun main() = runBlocking {
try {
val res1 = step1()
println(res1) // "Result1"
val res2 = step2(res1)
println(res2) // "Result2 from Result1"
val res3 = step3(res2)
println(res3) // "Final3 from Result2 from Result1"
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
This code is much flatter: each asynchronous call (step1(), step2(), etc.) looks like a normal function call. We use runBlocking in main just for this example to run coroutines; in Android you’d typically call coroutines from a lifecycleScope or similar. The delay() calls simulate non-blocking waits (instead of Thread.sleep). If an exception occurs in any step, it can be caught in one place. In contrast to deeply nested callbacks, the flow of operations is linear and easy to follow.
Coroutines support structured concurrency: you launch coroutines in specific scopes (e.g. tied to a ViewModel or Activity lifecycle) and have tools like CoroutineExceptionHandler or try/catch to manage errors. They also support cancellation. Overall, coroutines significantly improve code readability and maintainability compared to raw callbacks.
Under the hood, Kotlin provides utilities like suspendCoroutine or suspendCancellableCoroutine to bridge traditional callbacks into coroutines. For example, if you have an interface-based API with a single callback, you can wrap it:
suspend fun <T> Operation<T>.awaitResult(): T =
suspendCancellableCoroutine { cont ->
performAsync { value, error ->
if (error != null) cont.resumeWithException(error)
else cont.resume(value as T)
}
cont.invokeOnCancellation { cancel() }
}
This pattern shows that coroutines simply wait (suspend) until the callback calls cont.resume(...) or cont.resumeWithException(...). This integrates callbacks seamlessly into coroutine code.
Sometimes you need not just a single callback result, but a stream of values over time (for example, user location updates, sensor readings, or events). Kotlin provides the Flow API for asynchronous streams. A Flow can emit multiple values sequentially, and a consumer collects them. According to Kotlin’s docs:
“A suspending function asynchronously returns a single value, but how can we return multiple asynchronously computed values? This is where Kotlin Flows come in.”
A Flow is cold, meaning nothing happens until you call collect on it. You define a flow using a builder:
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.*
fun numbersFlow(): Flow<Int> = flow {
for (i in 1..3) {
delay(200) // simulate work
emit(i) // emit next value
}
}
fun main() = runBlocking {
numbersFlow().collect { value ->
println("Received $value")
}
}
This will print “Received 1”, then 2, then 3, each delayed by 200ms. Internally, flow { ... } is sequential and can suspend. It offers operators (map, filter, etc.) similar to RxJava streams. Critically, flows can be used to adapt callback-based APIs by using callbackFlow (a special builder). For example, Android’s callbackFlow lets you bridge listener callbacks into a Flow that you can consume with collect. This is more advanced, but in essence Flow is now the idiomatic replacement for many use cases that once used callbacks or Rx. It handles backpressure and is cancellable by default.
By using Flows, you get a clean, reactive style without the nesting of callbacks. You simply collect events as they come, and you can combine multiple flows, handle errors, and cancel collection easily. Callbacks and futures are the low-level primitives, while coroutines and flows give you higher-level, more composable constructs.
Besides coroutines and flows, you may encounter other patterns:
RxJava/RxKotlin: A powerful but complex reactive library that predates Kotlin Flow. It offers Observable/Flowable streams, operators, and threading control. Many legacy codebases still use Rx, but new projects often prefer built-in coroutines/flows for simplicity.
LiveData (Android): Part of Jetpack, LiveData is a lifecycle-aware observable data holder. It’s often used in MVVM to observe data from ViewModels, and under the hood it uses observers (callbacks). However, for pure asynchronous control flow, Flow is usually preferred over LiveData.
Callbacks within callbacks: If sticking with callbacks, you can still reduce “hell” by designing your APIs carefully: for example, avoid deep nesting by returning early on error, or breaking the chain into helper functions, or using named callbacks. But these are workarounds rather than true solutions.
CompletableFuture (JVM): You can use CompletableFuture in Kotlin if interoperating with Java code or libraries. It provides methods like thenApply, thenCompose, and handle for chaining. But in most Android/Kotlin apps, coroutines make these largely unnecessary.
Java Threads / Executors: Asynchronous tasks can also be managed with explicit threads or ExecutorService. However, manually dealing with thread pools is error-prone and low-level. Coroutines again abstract this in a safer, lighter way.
In general, the trend in Kotlin/Android is to move away from raw callbacks towards coroutines and flows. These modern tools make code more readable (“asynchronous code more linear”), handle concurrency concerns (cancellation, exceptions) automatically, and integrate with structured concurrency.
Basic Callback Example
// Define callback interface interface OnResultCallback { fun onSuccess(data: String) fun onError(error: Throwable) }
// Higher-order function using callback fun fetchData(callback: OnResultCallback) { try { // Simulate network delay Thread.sleep(2000) callback.onSuccess("Data loaded!") } catch (e: Exception) { callback.onError(e) } }
// Usage fetchData(object : OnResultCallback { override fun onSuccess(data: String) { println("Result: $data") // Result: Data loaded! } override fun onError(error: Throwable) { println("Error: ${error.message}") } })
Concise Syntax for above code
fun fetchData( onSuccess: (String) -> Unit, onError: (Throwable) -> Unit ) { try { Thread.sleep(2000) onSuccess("Data loaded!") } catch (e: Exception) { onError(e) } }
// Usage fetchData( onSuccess = { data -> println("Result: $data") }, onError = { error -> println("Error: ${error.message}") } )
Callback Hell
fetchUserData { user -> fetchUserPosts(user.id) { posts -> fetchComments(posts[0].id) { comments -> updateUI(comments) // Deep nesting → unreadable! } } }
Kotlin Coroutines
Replace callbacks with suspending functions:
suspend fun fetchUserData(): User = withContext(Dispatchers.IO) { /*...*/ }
suspend fun fetchUserPosts(userId: String): List<Post> = /*...*/
suspend fun fetchComments(postId: String): List<Comment> = /*...*/
// Usage in ViewModel
viewModelScope.launch {
try {
val user = fetchUserData()
val posts = fetchUserPosts(user.id)
val comments = fetchComments(posts[0].id)
_uiState.value = UiState.Success(comments)
} catch (e: Exception) {
_uiState.value = UiState.Error(e)
}
}
Callbacks are a fundamental pattern in Kotlin (and Android) for handling asynchronous actions and events. A callback is simply a function passed to another function to be invoked later. Kotlin’s function types and lambdas make defining and using callbacks concise (especially with trailing lambda syntax). In Android development, callbacks appear everywhere — from view click handlers to network request listeners — enabling non-blocking UI and background work.
However, excessive callbacks can lead to “callback hell,” a tangled pyramid of nested code that is hard to read and debug. To tackle this, developers use various strategies. Modern Kotlin heavily favors coroutines, which allow writing asynchronous code sequentially and avoid deep nesting. For streams of values, Flow provides a clean reactive interface instead of managing many callbacks. Other alternatives like RxJava exist, but in Kotlin coroutines and flows are now the preferred approach. By adopting these tools, you can write asynchronous Kotlin code that is clear, maintainable, and free from the pitfalls of callback hell.