Callback is a function that is passed to another function as an argument and executed after an asynchronous operation completes. In mobile development, callbacks are used to handle network request results, database operations, and animations. According to Apple Documentation (2025), closures in Swift are the primary form of callbacks and are used in URLSession, GCD, and Combine. In Android, callbacks are implemented through interfaces, Kotlin lambdas, and ListenableFuture.
Key Takeaways
Callback (callback function) is executable code that is passed to another function and called after a specific action completes. In mobile development, callbacks are a fundamental mechanism of asynchronous programming, allowing you to react to the completion of network requests, timers, animations, and I/O operations without blocking the main thread. Swift and Kotlin provide built-in syntactic constructs for creating callbacks — closures and lambdas respectively.
A higher-order function accepts another function as a parameter and calls it after executing its main logic. Control flow is passed back to the caller through the callback, hence the name. In iOS, callbacks are used in UIKit (UIView.animate animations), Foundation (URLSession.dataTask), and Combine (sink). In Android, callbacks are used in View.OnClickListener, Retrofit Callback, and Room DAO. Modern APIs increasingly replace callbacks with async/await or coroutines, but understanding callbacks is necessary for working with legacy code and low-level APIs.
Callbacks can be synchronous (called immediately inside the function) and asynchronous (called later from another thread or queue). Synchronous callbacks are used for sorting (comparators) and collection traversal. Asynchronous callbacks are used for network requests, file reading, and sensor handling. The difference is critical for understanding threading: synchronous callbacks execute on the same thread, asynchronous callbacks execute on a thread determined by the dispatcher (DispatchQueue in iOS, Dispatchers in Kotlin).
The callback mechanism on both platforms is based on the same principle: a function is passed as a first-class object and stored until execution. However, implementations differ due to different language paradigms. In iOS, a callback is a closure that captures variables from the surrounding context. In Android, a callback is most often implemented through anonymous classes or Kotlin lambda expressions, compiled into FunctionalInterface.
When an asynchronous function is called, the closure is stored on the heap along with captured variables. When the operation completes, the GCD or OperationQueue system places the callback in the appropriate queue (main queue or background queue). After execution, the callback is removed from memory when there are no strong references. Capture list ([weak self]) prevents the object from being retained after deallocation. Without a capture list, a retain cycle occurs where the object and callback reference each other.
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
completion(.success(data))
}
task.resume()
}
// Usage with [weak self]
fetchData { [weak self] result in
guard let self else { return }
switch result {
case .success(let data):
self.updateUI(data)
case .failure(let error):
self.showError(error)
}
}
In Android, a callback is passed through an interface or lambda. When an asynchronous operation is executed via ExecutorService or a coroutine, the callback is stored in memory until the background work completes. Kotlin lambdas are compiled into anonymous classes that capture external variables. The absence of weak references in the JVM requires manual management: nullifying the callback in onDestroy() or cancelling coroutines via Job.cancel(). ViewModel and LiveData solve this problem at the architectural component level.
interface Callback<T> {
fun onSuccess(data: T)
fun onError(error: Throwable)
}
class Repository {
fun loadData(callback: Callback<List<User>>) {
thread {
try {
val result = api.fetchUsers()
runOnUiThread { callback.onSuccess(result) }
} catch (e: Exception) {
runOnUiThread { callback.onError(e) }
}
}
}
}
// Usage with lambda
repository.loadData(object : Callback<List<User>> {
override fun onSuccess(data: List<User>) { showUsers(data) }
override fun onError(error: Throwable) { showError(error.message) }
})
The callback syntax is determined by the language's ability to work with functions as first-class objects. In Swift, closures have a concise syntax with automatic argument names ($0, $1). In Kotlin, lambdas also support it for a single argument. Differences appear in handling variable capture (capture list in Swift vs mutable references in Kotlin) and typing (Result
A Swift closure is a self-contained block of code that can be passed and used in another function. Closures can be global (named), nested, and expression-level. @escaping marks a closure that will be executed after the function returns — this is a mandatory requirement for asynchronous callbacks. Without @escaping, the closure can only be executed inside the function body. Trailing closure syntax allows passing the closure after the parentheses: fetchData { result in ... }.
typealias NetworkResult = (Result<[String: Any], Error>) -> Void
func performRequest(
url: URL,
then handler: @escaping NetworkResult
) {
let task = URLSession.shared.dataTask(with: url) { data, _, error in
handler(Result {
guard let json = try JSONSerialization.jsonObject(with: data)
else { throw NetworkError.invalidData }
return json as! [String: Any]
})
}
task.resume()
}
performRequest(url: url) { result in
switch result {
case .success(let json): process(json)
case .failure(let error): log(error.localizedDescription)
}
}
Kotlin supports higher-order functions that accept other functions as parameters. Callback in Kotlin is passed through a parameter of type (T) -> Unit or (T) -> R for return values. Kotlin coroutine suspend functions replace callbacks with sequential code, but callbacks remain in Java-compatible APIs and Android SDK (View.setOnClickListener, TextWatcher). Kotlin lambdas automatically capture val variables, var variables require mutability wrappers.
fun <T, R> processWithCallback(
input: T,
transform: (T) -> R,
onResult: (R) -> Unit
) {
thread {
val result = transform(input)
runOnUiThread { onResult(result) }
}
}
// Example with lambda
processWithCallback(
input = "Hello",
transform = { it.length },
onResult = { length ->
textView.text = "Length: $length"
}
)
Retain cycle is a situation where two objects hold strong references to each other, preventing the memory manager from releasing them. In Swift, a retain cycle occurs when a viewController captures a closure, and the closure captures self. In Kotlin/Java, a leak occurs when an Activity passes an inner class or lambda to a long-running background operation. According to WWDC Session 10216 (2024), improper closure management is the third most common cause of memory leaks in iOS applications.
Swift uses Automatic Reference Counting (ARC), which releases an object when the reference count reaches zero. Capture list [weak self] or [unowned self] in a closure prevents retain cycles. weak self creates an optional reference that becomes nil when the object is deallocated. unowned self assumes the object outlives the closure — violating this assumption causes a crash. weak self is recommended as the safe default.
class DataController {
var onDataUpdate: ((String) -> Void)?
func setupCallback() {
// Retain cycle!
onDataUpdate = { text in
self.process(text)
}
// Fixed: [weak self]
onDataUpdate = { [weak self] text in
guard let self else { return }
self.process(text)
}
}
func process(_ input: String) { }
}
In Android, a callback leak occurs when an Activity or Fragment passes a listener to a singleton component (e.g., EventBus or Service). WeakReference allows the garbage collector to release the Activity even if there is a weak reference to it. Lifecycle-aware components (LiveData, Flow) solve the problem automatically. Kotlin lambdas that capture Activity context can also cause leaks: the lambda implicitly holds a reference to this.
class SafeCallbackManager {
private val listeners = mutableListOf<WeakReference<(String) -> Unit>>()
fun addListener(callback: (String) -> Unit) {
listeners.add(WeakReference(callback))
}
fun notifyAll(data: String) {
val iterator = listeners.iterator()
while (iterator.hasNext()) {
val ref = iterator.next().get()
if (ref != null) ref(data)
else iterator.remove()
}
}
}
// Usage in Fragment
manager.addListener { result ->
// WeakReference does not hold Fragment
updateUI(result)
}
Callback Hell (also known as Pyramid of Doom) is a situation where many nested callbacks create a deeply nested code structure that is difficult to read and debug. Each subsequent step requires waiting for the previous one to complete, resulting in 5-10 levels of nesting. This problem is typical for sequential asynchronous operations: loading data → parsing → saving to DB → updating UI.
Swift 5.5 introduced asynchronous functions (async/await), which allow writing asynchronous code sequentially. AsyncSequence and AsyncStream replace callback-based iterations. The Combine framework provides operators like flatMap, merge, combineLatest for composing asynchronous streams without nesting. However, callbacks remain necessary for working with Objective-C APIs and third-party libraries without async support.
// Nested callbacks — Callback Hell
loginUser(credentials) { user in
fetchProfile(user.id) { profile in
downloadAvatar(profile.avatarUrl) { image in
cacheImage(image) { success in
updateUI(user, profile, image)
}
}
}
}
// async/await — solution
func loadUserExperience() async throws {
let user = try await loginUser(credentials)
let profile = try await fetchProfile(user.id)
let image = try await downloadAvatar(profile.avatarUrl)
try await cacheImage(image)
updateUI(user, profile, image)
}
Kotlin coroutines replace callbacks with suspend functions for sequential execution. Flow provides cold streams with operators like map, flatMapConcat, combine. CoroutineScope allows cancelling all running coroutines when a component is destroyed. Room, Retrofit, and other Jetpack libraries have built-in support for suspend functions, eliminating the need for callbacks in standard operations.
// Sequential callbacks — Callback Hell
api.login(credentials) { user ->
api.fetchProfile(user.id) { profile ->
api.download(profile.avatarUrl) { bytes ->
file.save(bytes) { result ->
textView.text = result.toString()
}
}
}
}
// Coroutines — solution
suspend fun loadUserData() {
val user = withContext(Dispatchers.IO) { api.login(credentials) }
val profile = withContext(Dispatchers.IO) { api.fetchProfile(user.id) }
val bytes = withContext(Dispatchers.IO) { api.download(profile.avatarUrl) }
withContext(Dispatchers.IO) { file.save(bytes) }
textView.text = "Done"
}
Callback and Delegate are two approaches to asynchronous notification, and the choice depends on architectural requirements. Callback is suitable for one-shot operations with a single result. Delegate is designed for multiple events with different method signatures. Apple recommends delegate for complex protocols with multiple methods, callback for simple closures with a single result. In Android, callback replaces delegate in most cases due to lambda support.
Callback is optimal for operations with a single result: network request, file reading, animation with a completion block. Advantages: compact syntax, no separate protocol, direct context capture. Disadvantages: complexity with multiple results (progress, pause, cancel), inability to send multiple times (if a callback can be called more than once — use a publisher instead).
Delegate is suitable for protocols with multiple required and optional methods: UITableViewDelegate, CLLocationManagerDelegate, Bluetooth connections. Advantages: clear typing for each method, documentation through the protocol, support for optional methods via @objc optional. Disadvantages: boilerplate code, weak reference to delegate is mandatory (weak var delegate), complexity with context capture.
Frequently Asked Questions
Callback is a specific case of a higher-order function. A higher-order function accepts another function as an argument or returns it. A callback is a function passed specifically for asynchronous execution after an operation completes. All callbacks are implemented through higher-order functions, but not every higher-order function is a callback.
By convention, a callback should be called exactly once — either success or failure. Multiple calls of the same callback is considered a design error. For multiple events (progress, data stream), use Observable, Publisher, or Flow — they support multiple value emissions. Some APIs violate this rule, leading to hard-to-find bugs.
Trailing closure is Swift syntactic sugar that allows passing a closure after the parentheses of a function call. If a function takes a closure as its last argument, it can be placed outside the parentheses: fetchData { result in ... }. For multiple closures, trailing closure applies only to the last one; the rest are named inside the parentheses. This improves readability of callback-based APIs.
Use WeakReference for long-lived listeners, cancel coroutines via Job.cancel() in onDestroy(), use lifecycleScope for automatic cancellation. ViewModel + LiveData/Flow solves the problem at the architectural level. Avoid passing Activity context into static callbacks — use Application context instead. Kotlin lambdas capture this implicitly, check with a memory profiler.
Async/await replaces callbacks for sequential asynchronous code, but not for event-driven architecture. Callbacks remain in system APIs (View.OnClickListener, URLSession delegates), progress callbacks, and third-party libraries. Full replacement is impossible due to backward compatibility. The modern strategy is to use async/await with callback wrappers (continuation in Swift, suspendCancellableCoroutine in Kotlin).
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also