Swift Concurrency: Replace URLSession Completion Handlers
Replace URLSession completion handlers with Swift async/await to flatten callback nesting, centralize error handling, and safely update UI on the main actor. Works on iOS 15+; older targets need fallbacks.
26 Dec 2025, 08:00 UTC

The Problem: Callback Hell in Networking Code
When fetching data from a REST endpoint with URLSession, the traditional completion‑handler pattern forces you to nest callbacks, handle errors in multiple places, and remember to dispatch UI updates back to the main thread. As the number of sequential requests grows, the code becomes hard to read and prone to forgotten DispatchQueue.main.async calls.
Thesis: Async/Await Turns Sequential Network Calls into Straight‑Line Code
By marking a function async and using await on the modern URLSession.data(from:delegate:) overload, the compiler builds a state machine that suspends the task while waiting for the network, freeing the thread. The resulting code reads like synchronous logic, yet remains fully non‑blocking and thread‑safe when combined with actors.
How the Modern URLSession API Works
Starting with iOS 15/macOS 12, URLSession provides an async variant:
func data(from url: URL, delegate: URLSessionDelegate? = nil) async throws -> (Data, URLResponse)
The method returns a tuple of the response data and metadata, or throws an error if the request fails. Because it is async, calling code must be inside an async context, such as another async function, a Task, or a TestCase marked async.
Worked Example: Fetching a JSON Payload
Suppose we need to load a user profile from https://api.example.com/user/123 and display the name on screen. The async/await version looks like this:
import UIKit
final class UserViewModel: ObservableObject {
@Published private(set) var name: String = ""
private let session = URLSession.shared
func loadUser(id: String) {
Task { [weak self] in
do {
let (data, _) = try await session.data(from: URL(string: "https://api.example.com/user/\(id)")!)
let decoder = JSONDecoder()
let user = try decoder.decode(User.self, from: data)
await MainActor.run { self?.name = user.name }
} catch {
// Handle network or decoding errors
print("Failed to load user: \(error)")
}
}
}
}
struct User: Decodable {
let id: String
let name: String
}
Explanation:
- The
Taskcreates a new concurrent context that starts immediately on a background thread. await session.data(...)suspends the task while the network request is in flight; the underlying thread returns to the system.- When the data arrives, the continuation resumes, we decode the JSON, and finally we hop back to the main actor to update the UI.
- All error handling is centralized in a single
catchblock, eliminating scatteredif letorguardstatements.
Trade‑Offs and Limitations
While the async/await model is ergonomic, it introduces two practical constraints:
- Deployment target: The async URLSession overload is only available on iOS 15, macOS 12, watchOS 8, tvOS 15 and later. Apps that must support earlier OS versions need to keep the completion‑handler API or provide a compatibility shim.
- CPU‑bound work: If you perform heavy computation after an
awaitwithout off‑loading it, you can still block the thread. The remedy is to wrap expensive work inTask.detachedor run it on an actor with a suitable priority.
Practical Ways to Verify the Behavior
You can confirm that the compiler really transformed your async function into a state machine without running the app:
swiftc -emit-sil -target x86_64-apple-ios15.0 -sdk $(xcrun --show-sdk-path --iphoneos) UserViewModel.swift
Run this in a Terminal window with the Xcode command‑line tools installed (no special permissions required). Look for functions named $s...async... in the SIL output; their presence indicates the async conversion.
At runtime, enable Xcode’s Thread Sanitizer and run the app under Instruments → Concurrency template to detect any data races or unexpected suspensions.
For unit testing, mark a test method as async:
func testLoadUser() async throws {
let viewModel = UserViewModel()
await viewModel.loadUser(id: "123")
XCTAssertEqual(viewModel.name, "Expected Name")
}
The test runner automatically awaits the continuation, making assertions straightforward.
Actionable Closing
If your project’s minimum deployment target is iOS 15 or newer, start by converting the simplest networking calls to async/await. Use a Task to launch them from UIKit view controllers or SwiftUI views, and rely on MainActor.run for UI updates. Keep an eye on CPU‑heavy sections and off‑load them with Task.detached when needed. For older OS versions, wrap the async call in an availability check and fall back to the completion‑handler version. With these steps you’ll gain clearer, safer asynchronous code while staying within Apple’s supported concurrency model.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.