Efficiently Migrating from Completion Handlers to Async-Await in Swift
With the introduction of Swift’s new concurrency model, async-await is transforming how we handle asynchronous tasks. In pre-async-await applications, completion handlers were a go-to method, helping us manage asynchronous code by passing results through closures. However, the readability and maintainability challenges of nested closures—often referred to as "callback hell"—made complex asynchronous workflows cumbersome.
Now, with async-await, Swift offers a more streamlined approach to managing asynchronous tasks, improving both readability and error handling. So, how can we leverage the benefits of async-await without needing to rewrite all our existing code? By using checked continuations, we can wrap completion handlers, enabling us to adopt async-await progressively and efficiently.
Let’s look at an example:
Transitioning Completion to Async-Await with withCheckedContinuation
Suppose we have a repository function that fetches launches. Here’s how the original function might look with a completion handler:
func fetchLaunches(completion: @escaping (Result<[Launch], Error>) -> Void) {
let endpoint = LaunchesAPIEndpoint()
apiService.fetch(from: endpoint) { result in
completion(result)
}
}To convert this to async-await, we can wrap the completion handler using Swift’s withCheckedContinuation, which allows us to bridge existing completion-based APIs with async-await.
Here’s the transformed code:
func fetchLaunches() async -> Result<[Launch], Error> {
let endpoint = LaunchesAPIEndpoint()
return await withCheckedContinuation { continuation in
apiService.fetch(from: endpoint) { result in
continuation.resume(returning: result)
}
}
}- Using
withCheckedContinuation: ThewithCheckedContinuationfunction provides acontinuationobject that we can use to resume the async-await flow once our completion block receives a result. This lets us effectively replace the completion handler's result handling with async-await.
Calling Async Functions in Serial and Parallel
In an async-await world, the approach to calling functions sequentially (serially) or concurrently (in parallel) can greatly impact performance, especially in resource-constrained environments like mobile applications. Here’s how to approach both scenarios, along with an example.
Get ankita kotadia - Senior iOS Engineer’s stories in your inbox
Join Medium for free to get updates from this writer.
Serial Execution
To execute async functions serially, you simply await each function call one after the other. Each call completes before moving to the next, which is useful when the operations depend on one another or when order matters.
func fetchDataSerially() async {
let launchResult = await fetchLaunches()
let messagesResult = await fetcMessages()
let companyResult = await fetchCompany()
print("Data fetched serially:", launchResult, messagesResult, companyResult)
}Here, each function (fetchLaunches, messagesresult`, and fetchCompany) is awaited before moving to the next. This serial execution ensures that the tasks complete in order but may take more time if each task is independent and could be run concurrently.
Parallel Execution
For tasks that can run independently, you can use async let declarations, allowing them to start concurrently. This can significantly reduce total execution time, especially for network requests or long-running computations.
func fetchDataInParallel() async {
async let launchResult = fetchLaunches()
async let messagesResult = fetchRockets()
async let companyResult = fetchCompany()
let (launches, messages, company) = await (launchResult, messagesResult, companyResult)
print("Data fetched in parallel:", launches, messages, company)
}Explanation: By using async let, each function call begins immediately and runs concurrently. The final await statement waits for all of them to complete, gathering the results once they are ready. This approach is particularly efficient when there are no dependencies between tasks.
With Swift’s async-await model, we have a cleaner, more readable way to manage asynchronous code, replacing the completion handlers that we used extensively before. By transforming our existing completion-based APIs using withCheckedContinuation, we can gradually adopt this new concurrency model without needing a full code rewrite. This approach allows us to modernize our codebase efficiently, making asynchronous calls easier to read and maintain.
Additionally, by understanding when to use serial versus parallel execution, we can optimize performance based on task dependencies and the resources available. Choosing serial execution helps when order matters or data dependencies exist, while parallel execution shines when tasks are independent and can run concurrently, cutting down wait times and improving app responsiveness.
Happy Coding :)






