Closures vs Async-Await: Asynchronous Operations in iOS

Nicolle Policiano
Policiano
Published in
4 min readMar 14, 2024

Asynchronous programming has always been crucial to developing responsive applications. It allows programs to perform time-consuming tasks, such as network requests or database operations, without freezing the user interface. This article explores two different approaches for managing asynchronous operations in Swift: Closures and Async-Await.

Closures

Closures in Swift offer a robust mechanism for handling asynchronous operations, enabling the capture of constants and variables from their surrounding context.

They’re particularly useful as completion handlers for asynchronous operations, allowing you to define the next steps right where the task begins. This approach makes your code easier to maintain by keeping related logic together, especially when dealing with success or failure outcomes of these tasks.

Example: Using Closures to handle asynchronous network requests

import Foundation

class Networking {

// Closure-based completion callback function

func fetchData(completion: @escaping (Result<Data?, Error>) -> Void) {
let url = URL(string: "https://api.example.com/data")!

let dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self else { return }

let result = self.mapToResult(data, error)
completion(result)
}

dataTask.resume()
}

private func mapToResult(_ data: Data?, _ error: Error?) -> Result<Data?, Error> {
if let error = error {
return .failure(error)
} else {
return .success(data)
}
}
}

// Usage

let networking = Networking()

networking.fetchData { result in
switch result {
case .success(let data):
print(data)
case .failure(let error):
print(error)
}
}

This example demonstrates how closures can effectively handle asynchronous callbacks in Swift.

The fetchData function takes a completion closure as a parameter. The Result<Data?, Error> neatly wraps the possible outcomes of the fetch operation. The @escaping attribute indicates that the closure may be called after the function returns, which is essential for asynchronous calls like network requests.

The closure is invoked upon the completion of the URLSession’s data task fetching process, leveraging captured context from its surrounding scope (mapToResult). This capability to capture and utilize context is useful in integrating the asynchronous operation's results with the overall application logic.

Async-Await

The async-await pattern is a modern approach that simplifies working with asynchronous operations. Once it can perform asynchronous operations linearly without nesting, it enhances code readability and maintainability. Moreover, it simplifies error handling, enabling the use of try-catch blocks to manage exceptions.

In Swift, marking a function with the async keyword before its return type indicates that it can perform tasks asynchronously. Within a function, the await keyword is used to suspend the function's execution, waiting for asynchronous tasks to complete without blocking the main thread.

Example: Using Async-Await to Fetch Data Asynchronously

import Foundation

// Asynchronous function definition

func fetchData() async throws -> Data {
let url = URL(string: "https://api.example.com/data")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
}

//Usage

Task {
do {
let data = try await fetchData()
print(data)
} catch {
print(error)
}
}

In this example, fetchData is an asynchronous function marked with async that retrieves data from a given URL. The await keyword pauses the function's execution until the network request is completed, allowing other tasks to run concurrently and keeping the app responsive.

The Task runs asynchronous code that can be awaited. This block will not block the main thread while fetchData is executing. A do-catch block handles errors thrown by fetchData.

Closures vs Async-Await

Closures

Closures have been a long-standing method for handling asynchronous tasks in iOS, supported across all iOS versions and widely adopted within the community. They offer the benefits of testability and a robust community knowledge base.

However, closures come with their set of challenges:

  • Memory Leaks and Retain Cycles: Closures can capture strong references to instances, like self, leading to potential memory leaks and retain cycles if not handled cautiously.
  • Complex Error Handling: Asynchronous tasks often involve multiple chained operations, complicating error handling. Closures demand specific error management strategies, especially in nested scenarios, making the code hard to maintain and read.
  • Code Readability and Nested Closures: The deep nesting of closures, or “callback hell,” affects code readability. It stems from layering asynchronous operations within closures, making the code difficult to understand and debug. This emphasizes careful organization and considering alternative patterns to simplify complex nested logic.

Async-await

Async-await offers a modern, readable syntax that simplifies writing asynchronous code, making error handling and concurrency management more intuitive. It’s particularly effective at reducing the risk of retain cycles and improving memory management.

However, its benefits come with certain considerations:

  • Limited Availability: It’s only available on iOS 13 and later, which can limit its use for applications that need to support older versions.
  • Memory Management: Although async-await reduces the risk of retain cycles through structured concurrency, memory management still requires careful attention. Capturing self weakly in closures for long tasks is essential to prevent memory leaks, as closures remain within async-await's scope.
  • Thread Management: Despite abstracting much of the complexity of managing threads directly, careful attention is needed to ensure UI updates are performed on the main thread.
  • Learning curve: Properly understanding asynchronous programming concepts, including the nuances of async-await, demands time and practice.

Conclusion

Asynchronous programming is crucial for creating responsive applications in iOS development. The async-await pattern and traditional closures are two distinct approaches to managing asynchronous operations, each with its own advantages and challenges.

Although async-await represents a significant step forward in simplifying asynchronous code, it requires a deep understanding of its nuances and limitations for effective use. Closures remain indispensable for projects that require backward compatibility or where developers prefer a more traditional approach.

The choice between the two depends on project’s requirements, target iOS version, and developer familiarity with these methodologies, highlighting that both approaches are valuable and can coexist within the Swift ecosystem.

--

--