Most iOS codebases we open still have a networking layer built on completion handlers. Three closures deep, a weak self at every level, and at least one error path that forgets to call the callback. async/await arrived in Swift 5.5 back in 2021 and runs on iOS 13 and later, so old devices stopped being an excuse years ago. What actually slows teams down now is the rest of the model: cancellation, actors, Sendable, and the stricter checks in Swift 6. This is the order we migrate in, and the mistakes we keep catching in code review.
Replace Completion Handlers From the Bottom Up
Start with the lowest layer, usually the API client. URLSession already has async methods such as data(for:), so a request function can become a plain async throws function that returns a decoded model. For your own callback APIs, or an SDK you cannot change, wrap them with withCheckedThrowingContinuation. The checked version crashes if you resume twice and logs a warning if you never resume, which is exactly the kind of bug completion handlers used to hide. Keep the old closure-based signature for a while as a thin wrapper around the new async one. That way screens can move over one at a time instead of in a single huge pull request.
Tasks and Cancellation
Every async function runs inside a task. Task { } inherits the actor and priority of the place it was created. Task.detached inherits nothing, and in app code you almost never need it. Cancellation is cooperative. Calling cancel() only sets a flag. URLSession and Task.sleep check that flag and throw, but your own loops keep running unless they call Task.checkCancellation() or look at Task.isCancelled. Search-as-you-type is the classic case. Keep a handle to the current search task, cancel it when the user types again, and start the new task with a 300 ms Task.sleep. Because the sleep throws on cancellation, you get debouncing for free and old requests never overwrite newer results.
Running Work in Parallel
| Tool | Use it when | Watch out for |
|---|---|---|
| Sequential await | The second call needs the result of the first | Accidentally serialising calls that could run together |
| async let | A fixed, small number of independent calls, such as profile, balance, and notifications for a home screen | Child tasks are cancelled if you leave the scope without awaiting them |
| withThrowingTaskGroup | A dynamic number of calls, such as uploading 20 photos | Starting hundreds of requests at once. Limit how many run at the same time |
| Task { } | Bridging from synchronous code like a button action | Nobody owns or cancels it unless you keep the handle |
Prefer async let and task groups wherever they fit. They are structured: child tasks cannot outlive their parent, errors propagate up, and cancelling the parent cancels the children. A home screen that made three sequential calls of 300 ms each can drop from about 900 ms to roughly the slowest single call just by switching to async let.
Actors and @MainActor
An actor protects its mutable state by letting only one caller in at a time. It is a good fit for things like an image cache or a token store. The catch is reentrancy. When an actor method hits an await, other callers can run before it resumes, so any state you read before the await may be stale afterwards. A token refresher shows this well. If five requests get a 401 at the same moment, a naive actor will refresh five times. Store the in-flight refresh as a Task inside the actor and let every caller await that same task.
Mark view models and anything that touches UI with @MainActor. Since Swift 6.2 a module can opt into main actor isolation by default, and new app projects in Xcode 26 turn it on. We like that for the app target because most code there is UI code anyway. For networking, parsing, or image processing modules we leave it off and explicitly move heavy functions off the main actor with nonisolated, or @concurrent on Swift 6.2 and later.
Sendable and Swift 6 Strict Checking
Sendable marks a type as safe to pass between concurrency domains. Structs and enums made of Sendable values usually qualify without much effort. Classes need to be final with only immutable let properties, or become actors, or be marked @unchecked Sendable with real locking behind them, such as Mutex from the Synchronization framework on iOS 18 or OSAllocatedUnfairLock on iOS 16. The Swift 6 language mode turns data race warnings into compile errors. On a mid-sized app, switching everything on at once can produce hundreds of warnings, and people stop reading them after the first fifty. Go module by module instead.
- 1Update to the current Xcode but keep the Swift 5 language mode for now.
- 2Pick a leaf module with few dependencies, such as models or utilities, and set Strict Concurrency Checking to Complete for that module only.
- 3Fix the warnings: make models Sendable value types, turn shared mutable singletons into actors or put them on the main actor.
- 4Use @preconcurrency import for third-party libraries that have not added Sendable annotations yet, and write down which ones so you can remove it later.
- 5Switch that module to the Swift 6 language mode so new violations fail the build.
- 6Move up the dependency graph to networking, then feature modules, and finish with the app target.
The .task Modifier in SwiftUI
In SwiftUI, load data with the .task modifier instead of onAppear with a Task inside it. The .task modifier starts when the view appears and is cancelled automatically when the view disappears. With .task(id:), SwiftUI cancels the running task and starts a new one whenever the id changes, which makes a search field or a filter picker almost trivial. Pull to refresh works the same way through .refreshable, which takes an async closure and keeps the spinner visible until it finishes.
Mistakes We See in Code Review
- Sprinkling Task { } everywhere to silence the compiler, creating work nobody owns or cancels.
- Blocking on async code with DispatchSemaphore or DispatchGroup.wait. The cooperative thread pool has roughly one thread per CPU core, so blocking it can freeze the app or deadlock.
- Assuming an actor method runs from start to finish without interruption. Re-check state after every await.
- Reaching for Task.detached just to get off the main thread, which also throws away priority and task-local values.
- Silencing Sendable errors with @unchecked Sendable or nonisolated(unsafe) when there is no lock behind it.
- Decoding large JSON or resizing images inside a @MainActor view model, then wondering why scrolling stutters.
If you had to add @unchecked Sendable to make the build pass, you have not fixed a data race. You have hidden it from the one tool that was trying to find it.
Testing Async Code
Both XCTest and Swift Testing accept async test functions, so most expectation and waitForExpectations boilerplate can go. Mark the test async throws and await the call directly. In Swift Testing, use confirmation() for callbacks or events that should fire a specific number of times. Inject the API client through a protocol so tests use a fake that returns instantly, and avoid real Task.sleep calls in tests. Inject a Clock instead so debouncing can be tested without waiting. Write at least one test that cancels a task and asserts it stops, because cancellation paths are where async bugs hide. Swift Testing also runs tests in parallel by default, which tends to expose shared global state you did not know you had.
Most teams we work with already write some async/await. What they lack is a shared approach to actors, Sendable, and the Swift 6 migration. Our iOS Swift training for teams covers these topics with exercises on a real-world style codebase, so everyone on the team reviews concurrency code using the same rules.
Key takeaways
- Migrate from the networking layer upwards, wrapping old callback APIs with checked continuations.
- Cancellation is cooperative, so check for it in your own loops and keep handles to tasks you start.
- Use async let and task groups for parallel work, and only fall back to unstructured Task when bridging from synchronous code.
- Remember actor reentrancy, and share in-flight work such as token refreshes instead of repeating it.
- Turn on strict concurrency checking one module at a time, starting with leaf modules and ending with the app target.


