The Complete Guide to Swift Async Await Error Handling
Swift 5.5 brought a concurrency model that actually feels like it belongs in the language. Async/await lets you reason about work that suspends and resumes without hunting through nested closures. But once you start writing real code, the sunny-day path is rarely the whole story. Things break. Networks hiccup. Payloads surprise you. This guide lays out the mechanics of error handling in async functions—from the first try await you write, through task groups, and into the testing trenches.

Understanding Throwing Async Functions
A function can be both async and throws, no special ceremony required. You slap the keywords on and the compiler does the rest. Callers must use try await. Forget the try and the compiler stops you cold. That’s not pedantry; it keeps the failure path visible even when the async machinery hums beneath the surface.
func fetchUserData(id: Int) async throws -> User {
let request = URLRequest(url: URL(string: "https://api.example.com/users/\(id)")!)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw NetworkError.badResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
The try await combo is non-negotiable. Miss try and you get a build error, not a runtime surprise. That explicitness is a feature—failure points stand out even when the control flow hops between suspension points.
Propagation and the Caller’s Responsibility
Errors bubble up the usual way. If a function calls a throwing async function and doesn’t catch the error, it needs to be marked async throws itself. The chain stays obvious. Take a higher-level function that stitches together a couple of network calls:
func loadDashboard() async throws -> Dashboard {
let user = try await fetchUserData(id: currentUserID)
let notifications = try await fetchNotifications(for: user.id)
return Dashboard(user: user, notifications: notifications)
}
If something goes wrong in either fetch, the error walks up to whoever called loadDashboard(). No surprise. It’s the same story Swift has always told about errors, now extended cleanly into async land.

Catching Errors with Do-Catch in Async Contexts
To handle a failure right where it happens, wrap the call in a do block and tack on one or more catch clauses. The structure feels exactly like synchronous error handling—just add await inside the try expression.
func updateProfileImage(data: Data) async {
do {
let result = try await uploadImage(data)
print("Uploaded: \(result.url)")
} catch NetworkError.timeout {
print("Upload timed out; retrying later")
} catch {
print("Upload failed: \(error.localizedDescription)")
}
}
Pattern matching inside catch works the same as ever. Target a specific error type, or use a bare catch as a wide net. That granularity matters when a timeout wants a retry but a decoding failure probably means you should stop trying.
Avoiding Silent Failures
A mistake I see routinely is catching an error and then doing precisely nothing with it. In async code, that can leave a UI hanging or data missing with no breadcrumbs. Always log or otherwise handle the error. If the operation is essential, let the error propagate. For optional work, consider a retry or a fallback value—and at least whisper something into the log.
func loadOptionalWidget() async -> Widget? {
do {
return try await fetchWidget()
} catch {
Logger.network.error("Widget fetch failed: \(error)")
return nil
}
}
That log line doesn’t fix the problem, but two weeks from now when a user reports a blank widget, you’ll be glad it’s there.
Structured Concurrency and Error Handling
Structured concurrency—async let and task groups—adds another layer. When you run multiple child tasks at once, a failure in one can ripple into the others. The exact behavior depends on which tool you pick.
Async Let: Implicit Cancellation on Error
async let binds child tasks to local names. If one of them throws and you try to read its value with try await, the error fires at that point. The runtime cancels the other child tasks automatically. That’s a built-in safety net: no point letting a sibling task keep chewing CPU when the overall operation has already failed.
func loadProfile() async throws -> Profile {
async let details = fetchDetails()
async let avatar = fetchAvatar()
return try await Profile(details: details, avatar: avatar)
}
If fetchDetails() throws, the try await on details propagates the error instantly. The runtime cancels the still-in-flight fetchAvatar() task. You don’t have to wire up cancellation by hand. Just remember that cancellation is cooperative: if your async functions do heavy computation, they should check Task.isCancelled periodically.

Task Groups: Collecting Results with Errors
Task groups give you finer control. You add child tasks on the fly and iterate over what comes back. There are two flavors: withThrowingTaskGroup and the non-throwing withTaskGroup. The throwing variant rethrows the first error it meets.
func fetchAllProducts(ids: [Int]) async throws -> [Product] {
try await withThrowingTaskGroup(of: Product.self) { group in
for id in ids {
group.addTask {
return try await fetchProduct(id: id)
}
}
var products: [Product] = []
for try await product in group {
products.append(product)
}
return products
}
}
The loop for try await product in group rethrows the first error it encounters. When that happens, all remaining tasks in the group get cancelled. If you want to collect both hits and misses, use a non-throwing withTaskGroup and have each child return a Result value. Then you sort the outcomes after the group finishes.
Designing Custom Error Types for Async Code
Async work tends to fail in several distinct ways: a bad URL you could have caught earlier, a network outage, a server that’s having a bad day, a JSON payload that doesn’t match the model, a timeout. A tidy error enum makes those modes explicit. Conform to Error and, if you want user-facing text, LocalizedError.
enum DataServiceError: Error {
case invalidURL
case networkUnavailable
case serverError(statusCode: Int)
case decodingFailed(underlying: Error)
case timeout
}
extension DataServiceError: LocalizedError {
public var errorDescription: String? {
switch self {
case .networkUnavailable:
return "The network connection appears to be offline."
case .serverError(let code):
return "The server returned an error (code \(code))."
case .timeout:
return "The request timed out. Please try again."
default:
return "An unexpected error occurred."
}
}
}
Good descriptions speed up debugging and can be surfaced in the UI after some polite massaging. Avoid leaking raw underlying errors directly to the user unless they add genuinely actionable detail.
Retry Strategies and Resilience
Distributed systems hiccup. A transient failure doesn’t have to mean a dead feature. A retry loop with a delay is simple to write in async Swift. Use Task.sleep between attempts and stay mindful of cancellation—a task that’s no longer needed shouldn’t keep knocking on the server’s door.
func fetchWithRetry(attempts: Int = 3, delay: Duration = .seconds(2)) async throws -> Data {
for attempt in 1...attempts {
do {
return try await performRequest()
} catch {
if attempt == attempts { throw error }
try await Task.sleep(for: delay)
}
}
fatalError("Unreachable")
}
The loop either returns data or throws. If the task gets cancelled while sleeping, Task.sleep throws a CancellationError, which bubbles up and stops the retry train. You can also sprinkle in explicit Task.isCancelled checks before each attempt if you want to bail even earlier.
Exponential Backoff
A fixed delay works, but pounding a recovering server with the same rhythm isn’t polite. Bump the delay each time. A quick multiplier does the job.
let delay = Duration.seconds(Double(attempt) * 1.5)
try await Task.sleep(for: delay)
That small change reduces load on a wobbly backend and often gives it enough room to get back on its feet.
Testing Async Error Paths
XCTest handles async tests natively. To assert that a throwing async call actually throws, wrap it in a throwing closure and hand it to XCTAssertThrowsError. The await sits inside the expression, not outside.
func testFetchUserInvalidIDThrows() async throws {
let service = UserService()
do {
_ = try await service.fetchUser(id: -1)
XCTFail("Expected error not thrown")
} catch let error as DataServiceError {
XCTAssertEqual(error, .invalidURL)
}
}
When you’re testing task group failures, isolate the group in a test that feeds it mock tasks designed to throw at specific points. Confirm that the group cancels the leftover tasks and that the correct error walks out. Swift’s concurrency runtime is deterministic enough to make these scenarios testable without flakiness.
FAQ
- What’s the difference between
try awaitandawait try? try awaitis the right order.trymarks the expression as possibly throwing, andawaitmarks the suspension point. Flip them and the compiler rejects it outright.- How do I handle errors from
async letwhen I only need partial results? - Don’t use
async letwith throwing functions if you want to tolerate a failure. Reach for a task group instead, have each child return aResulttype, then filter out the failures after the group completes. - Can I use
try?andtry!with async calls? - Yes.
try? await someFunction()gives you an optional that’snilon error.try! awaitcrashes if an error is thrown.try?is fine for truly optional work, but explicitdo-catchusually makes error handling intentions clearer. - Why doesn’t my async function need
throwseven though I usetry awaitinside? - If the function catches every error internally, nothing propagates out, so
throwsisn’t needed. The compiler figures that out from the body. Mark itthrowsonly when you want callers to deal with the failure.
Wrapping Up
Async/await doesn’t reinvent Swift’s error story; it extends it. Throwing async functions, structured concurrency, and clear error types let you build apps that fail in predictable, recoverable ways. Watch out for swallowed errors, respect cancellation, and test the unhappy paths. The result is code that reads plainly and behaves sanely when the network inevitably acts up.