Swift Concurrency in Production: What async/await and Actors Actually Fix
Most iOS crash reports I've debugged over the years fall into a depressingly small set of categories, and a surprising share of them are concurrency bugs wearing disguises: a callback landing on the wrong queue, two threads mutating the same array, a DispatchQueue.main.async someone forgot. Swift Concurrency doesn't just make async code prettier — it makes several of those crash classes impossible to write.
Here's what actually changed for me in production code, beyond the syntax.
The bug async/await deletes: callback-order chaos
Completion-handler code has two failure modes that the compiler never sees: calling the completion twice, and not calling it at all. Both are invisible until a user hits the path where a retain cycle or an early return swallows your callback.
// The old world — nothing stops you from forgetting `completion` on the error path
func loadProfile(completion: @escaping (Result<Profile, Error>) -> Void) {
api.fetchUser { user in
guard let user else { return } // completion never called. Spinner forever.
api.fetchProfile(for: user) { profile in
completion(.success(profile))
}
}
}
// The new world — every path must produce a value or throw. The compiler checks.
func loadProfile() async throws -> Profile {
let user = try await api.fetchUser()
return try await api.fetchProfile(for: user)
}
That guard ... return bug — the eternal spinner — is a compile error under async/await, because a function that returns Profile must return a profile. This single property has eliminated a whole genre of "app feels stuck" reports in code I've worked on.
Actors: for state, not for everything
An actor is a reference type whose state can only be touched by one task at a time. The classic use case is a cache:
actor ImageCache {
private var store: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? { store[url] }
func insert(_ image: UIImage, for url: URL) { store[url] = image }
}
No locks, no sync queues, no data races — the compiler enforces serialized access. My rule of thumb after using them in real apps:
- Use an actor when you have mutable state accessed from multiple tasks: caches, connection pools, rate limiters, analytics buffers.
- Don't use an actor for stateless helpers or things that are already confined to the main thread. Every
awaithop into an actor is a potential suspension point, and sprinkling them everywhere turns simple call stacks into interleaved puzzles.
And the one that matters most in UIKit/SwiftUI apps: @MainActor. Annotating your view models with it turns "which queue am I on?" from a runtime question into a type-system fact. The pattern that used to be a code-review checklist item — did you dispatch back to main before touching the view? — simply stops existing.
The migration traps
Moving a codebase from completion handlers and Combine, three things bit me or teammates more than once:
1. Task { } is not DispatchQueue.global().async { }. An unstructured Task inherits the actor context where it's created. A Task created inside a @MainActor view model starts on the main actor — your "background work" isn't in the background until the first real suspension. Use Task.detached sparingly and deliberately, or push the work into a non-isolated function.
2. Bridging with withCheckedContinuation needs the same discipline the old code lacked. A continuation must be resumed exactly once. If the completion handler you're wrapping has a path that never fires (see above), the continuation leaks and the task hangs forever — same bug, new outfit. Wrap only APIs whose contract you trust, and prefer withCheckedThrowingContinuation in debug builds since it traps on double-resume.
3. Combine and async/await coexist better than the internet suggests. You don't need a big-bang rewrite. .values turns any publisher into an AsyncSequence, which lets you migrate consumers first and leave stable publisher pipelines alone:
for await status in networkMonitor.statusPublisher.values {
await handle(status)
}
Where it pays off most
The apps where I've felt the biggest difference are the ones with high-stakes state: payment flows, session and authentication handling, anything where "two things happened at once" used to mean a support ticket. Structured concurrency's guarantee — child tasks complete or cancel before their parent scope exits — means the app can't wander into a half-finished state because someone backgrounded it mid-flow.
If you're starting a migration today: turn on strict concurrency checking (SWIFT_STRICT_CONCURRENCY = complete) in a branch, and treat every warning as a real bug report from the future. Most of them are.