URLSession with async/await in Swift
Networking is a fundamental aspect of almost every modern iOS application. For years, Swift developers have relied on URLSession and its completion handler-based APIs to fetch data, upload files, and interact with web services. While functional, these callback-driven patterns often led to nested closures, complex error handling, and a phenomenon affectionately (or not so affectionately) known as the "pyramid of doom."
With the advent of Swift's structured concurrency, async/await has revolutionized how we write asynchronous code, making it more readable, maintainable, and less prone to errors. URLSession has fully embraced this paradigm shift, offering new async/await-native methods that dramatically simplify networking operations.
In this article, we'll dive deep into using URLSession with Swift's async/await. We'll explore the new APIs, see how they streamline common tasks, improve error handling, and make your networking code a joy to work with. If you're ready to modernize your app's networking layer, you're in the right place!
The Traditional Way: Completion Handlers
Before async/await, performing a network request with URLSession typically involved calling a method like dataTask(with:completionHandler:). This method would immediately return a URLSessionDataTask instance, and the actual result (data, response, or error) would be delivered asynchronously to a closure you provided.
Here's a quick reminder of what that looked like:
func fetchPostsTraditional(completion: @escaping (Result<[Post], Error>) -> Void) {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
completion(.failure(NetworkError.invalidURL))
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
completion(.failure(NetworkError.invalidResponse))
return
}
guard let data = data else {
completion(.failure(NetworkError.noData))
return
}
do {
let posts = try JSONDecoder().decode([Post].self, from: data)
completion(.success(posts))
} catch {
completion(.failure(NetworkError.decodingError(error)))
}
}.resume() // Don't forget to resume the task!
}
// Example usage:
enum NetworkError: Error, LocalizedError {
case invalidURL
case invalidResponse
case noData
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidURL: return "The URL provided was invalid."
case .invalidResponse: return "Received an invalid HTTP response."
case .noData: return "No data was returned from the server."
case .decodingError(let error): return "Failed to decode data: \(error.localizedDescription)"
}
}
}
struct Post: Decodable, Identifiable {
let id: Int
let title: String
let body: String
}
// In a ViewController or ViewModel:
// fetchPostsTraditional { result in
// switch result {
// case .success(let posts):
// print("Fetched \(posts.count) posts.")
// case .failure(let error):
// print("Error fetching posts: \(error.localizedDescription)")
// }
// }
While functional, this approach often led to:
- Callback Hell: Deeply nested closures when chaining multiple asynchronous operations.
- Error Handling Complexity: Each layer of the callback chain needed its own error checks.
- Readability Issues: The flow of execution jumped around, making it harder to follow.
resume(): Easy to forget to call.resume()on thedataTask.
Embracing Modern Networking: async/await
Swift 5.5 introduced async/await, a powerful feature that allows you to write asynchronous code that looks and behaves like synchronous code. URLSession was quickly updated to provide async/await-native methods, making the traditional completion handlers largely obsolete for new code.
The key methods you'll now use are:
data(from: URL, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse): For fetching all data at once.bytes(from: URL, delegate: URLSessionTaskDelegate?) async throws -> (URLSession.AsyncBytes, URLResponse): For streaming data, useful for large files or progressive downloads.
These methods are async because they perform work that might take time without blocking the current thread, and throws because they can fail, allowing you to use Swift's native do-catch error handling.
Basic Data Fetching with data(from:delegate:)
Let's refactor our fetchPosts function using async/await:
func fetchPostsAsync() async throws -> [Post] {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
throw NetworkError.invalidURL
}
// Perform the network request and await its result
let (data, response) = try await URLSession.shared.data(from: url)
// Type-cast the response and check status code
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.invalidResponse
}
// Decode the data
do {
let posts = try JSONDecoder().decode([Post].self, from: data)
return posts
} catch {
throw NetworkError.decodingError(error)
}
}
// How to call it:
// This must be called from an async context, e.g., a Task
Task {
do {
let posts = try await fetchPostsAsync()
print("Fetched \(posts.count) posts using async/await.")
// Update UI on the main actor if needed
// await MainActor.run {
// self.posts = posts
// }
} catch {
print("Error fetching posts with async/await: \(error.localizedDescription)")
}
}
Notice the immediate improvements:
- Linear Flow: The code reads from top to bottom, just like synchronous code. No more jumping into closures.
- Explicit Error Handling:
try awaitclearly indicates a failable asynchronous operation, anddo-catchhandles errors naturally. - No
resume(): The task starts automatically. - Tuple Return: The
dataandresponseare returned directly as a tuple, making them readily available.
Handling Errors Gracefully
With async/await, error handling becomes much more straightforward. You use do-catch blocks, just as you would for any other throwing function. Defining custom error types, like our NetworkError enum, allows you to provide specific context for different failure scenarios.
enum NetworkError: Error, LocalizedError {
case invalidURL
case invalidResponse(statusCode: Int) // Added statusCode for more detail
case noData
case decodingError(Error)
case unknown(Error)
var errorDescription: String? {
switch self {
case .invalidURL: return "The URL provided was invalid."
case .invalidResponse(let statusCode): return "Received an invalid HTTP response with status code \(statusCode)."
case .noData: return "No data was returned from the server."
case .decodingError(let error): return "Failed to decode data: \(error.localizedDescription)"
case .unknown(let error): return "An unknown network error occurred: \(error.localizedDescription)"
}
}
}
// ... inside fetchPostsAsync
// ...
guard let httpResponse = response as? HTTPURLResponse else {
// If it's not an HTTPURLResponse, it's an unexpected response type
throw NetworkError.invalidResponse(statusCode: -1) // Or a more specific error
}
guard httpResponse.statusCode == 200 else {
throw NetworkError.invalidResponse(statusCode: httpResponse.statusCode)
}
// ...
By providing more specific error cases, your app can react more intelligently to different failure conditions, offering better feedback to the user or logging more useful information for debugging.
Beyond Basic Fetching: bytes(from:delegate:) for Streaming
For scenarios involving very large files or when you need to process data as it arrives (e.g., displaying download progress, parsing streaming data), URLSession offers bytes(from:delegate:). This method returns an URLSession.AsyncBytes object, which conforms to AsyncSequence. This allows you to iterate over the incoming bytes using a for await loop.
func downloadLargeFile(url: URL) async throws -> Data {
let (asyncBytes, response) = try await URLSession.shared.bytes(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.invalidResponse(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
var downloadedData = Data()
var totalBytesReceived = 0
let expectedContentLength = httpResponse.expectedContentLength // Can be -1 if not available
print("Starting download for \(url.lastPathComponent), expected size: \(expectedContentLength > 0 ? "\(expectedContentLength) bytes" : "unknown")")
for try await byte in asyncBytes {
downloadedData.append(byte)
totalBytesReceived += 1
// Example: Update progress (ensure on MainActor for UI)
if expectedContentLength > 0 {
let progress = Double(totalBytesReceived) / Double(expectedContentLength)
// print("Download progress: \(Int(progress * 100))%")
}
}
print("Download complete. Received \(totalBytesReceived) bytes.")
return downloadedData
}
// Example usage:
Task {
if let largeFileUrl = URL(string: "https://speed.hetzner.de/100MB.bin") { // Example large file
do {
let fileData = try await downloadLargeFile(url: largeFileUrl)
print("Successfully downloaded \(fileData.count) bytes.")
} catch {
print("Error downloading large file: \(error.localizedDescription)")
}
}
}
This for await loop makes processing streamed data incredibly elegant, eliminating the need for URLSessionDelegate methods like urlSession(_:dataTask:didReceive:) for simple streaming tasks.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ URLSession.bytes│──────►│ AsyncBytes │──────►│ Process Chunk 1 │
│ (URL) │ │ (AsyncSequence) │ │ (e.g., append) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ ▲
▼ │
┌─────────────────┐ │
│ Process Chunk 2 │───────┘
└─────────────────┘
Configuring Your URLSession
While URLSession.shared is convenient, for most real-world applications, you'll want to create your own URLSession instance with specific configurations. This allows you to control aspects like caching policies, timeouts, and custom headers.
func createCustomURLSession() -> URLSession {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30.0 // 30 seconds
configuration.timeoutIntervalForResource = 60.0 // 60 seconds for the entire resource
configuration.httpAdditionalHeaders = ["User-Agent": "SwiftByRahulApp/1.0", "Accept": "application/json"]
configuration.urlCache = URLCache(memoryCapacity: 4 * 1024 * 1024, diskCapacity: 20 * 1024 * 1024, diskPath: "SwiftByRahulCache") // 4MB memory, 20MB disk cache
return URLSession(configuration: configuration)
}
// Then use it:
let customSession = createCustomURLSession()
func fetchUsers(session: URLSession) async throws -> [String] {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/users") else {
throw NetworkError.invalidURL
}
let (data, response) = try await session.data(from: url)
// ... process data and response ...
return ["User 1", "User 2"] // Placeholder
}
Task {
do {
let users = try await fetchUsers(session: customSession)
print("Fetched \(users.count) users with custom session.")
} catch {
print("Error fetching users with custom session: \(error.localizedDescription)")
}
}
Concurrency and Parallel Requests
One of the most compelling advantages of async/await is how easily it enables concurrent operations. If you need to fetch multiple resources simultaneously, async let is your friend.
func fetchMultipleResources() async throws {
let customSession = createCustomURLSession() // Reusing our custom session
// Start both requests concurrently
async let postsTask = fetchPostsAsync()
async let usersTask = fetchUsers(session: customSession)
// Await both results. This will suspend until both are complete.
let posts = try await postsTask
let users = try await usersTask
print("Fetched \(posts.count) posts and \(users.count) users concurrently.")
}
Task {
do {
try await fetchMultipleResources()
} catch {
print("Error fetching multiple resources: \(error.localizedDescription)")
}
}
With async let, Swift automatically manages the underlying tasks, ensuring that both fetchPostsAsync() and fetchUsers() run in parallel. The try await calls then wait for each result, making the concurrent logic incredibly clear. For more complex scenarios involving a dynamic number of tasks or stricter control over concurrency, TaskGroup offers even more power.
Best Practices and Tips
To make the most of URLSession with async/await, consider these best practices:
- Define Custom Error Types: Use enums that conform to
Error(and optionallyLocalizedError) to provide clear, actionable error information. - Validate URLs Early: Always check if a
URLcan be constructed from a string, especially if the string comes from an external source. - Update UI on the Main Actor: Any code that modifies the UI must run on the
MainActor. You can achieve this by marking your UI update functions with@MainActoror by wrapping UI updates inawait MainActor.run { ... }. - Leverage Task Cancellation:
URLSession'sasync/awaitmethods are cancellation-aware. If theTaskthey are running in is cancelled, the underlying network request will also be cancelled. You should check for cancellation within long-running loops (e.g., inbytes(from:)) usingTask.checkCancellation(). - Dependency Injection for
URLSession: For testability and flexibility, inject yourURLSessioninstance into your networking layer rather than directly usingURLSession.shared. This allows you to substitute a mock session during testing. - Secure Your Connections: Always use HTTPS.
URLSessionrespects App Transport Security (ATS) by default, which enforces secure connections.
Summary
Swift's async/await has brought a breath of fresh air to networking with URLSession. The new data(from:delegate:) and bytes(from:delegate:) methods transform what was once a callback-ridden, error-prone process into a linear, readable, and robust experience. By embracing structured concurrency, you can write cleaner, more maintainable networking code that's easier to reason about and debug.
Modernizing your app's networking stack to use async/await is a highly recommended step that will pay dividends in code quality and developer experience.
Happy Swifting!