How to Migrate a Large Codebase to Swift Concurrency: A Step-by-Step Guide

Moving a big existing codebase to Swift’s modern concurrency model isn’t a weekend hackathon project. It asks for planning, solid discipline, and a real feel for both the old and new ways of doing things. I’ve walked several teams through this migration—sometimes kicking and screaming—and while the payoffs are tangible (fewer data races, code that actually reads top-to-bottom, and snappier behavior under load), the road is never a straight line. This post maps out the incremental approach I’ve sharpened on projects with hundreds of thousands of lines of Swift. Expect to touch dependency mapping, actor isolation, testing rhythms, and the classic traps that can stall a migration cold.
Why Migrate Now? Understanding the Stakes
Swift’s concurrency features landed in Swift 5.5 and have kept maturing through 5.9 and onward. They’re a clean break from callback soup and manual DispatchQueue juggling. The old patterns—completion handlers, queue management, ad-hoc synchronization—are verbose, sure, but more than that, they’re a steady drip of bugs you can never quite reproduce. With the compiler enforcing data-race safety in Swift 6 language mode, kicking the can down the road means building technical debt that will eventually lock you out of newer toolchains and APIs. In a large codebase, the cost of retrofitting isolation boundaries just keeps swelling with every feature you bolt onto the old concurrency model.
On the practical side, moving to async/await, Task groups, and actors hands you stack traces that aren’t a tangled mess, cancellation propagation that actually propagates, and a serious reduction in boilerplate. For a team, that means quicker code reviews and far fewer late nights chasing a race condition buried inside three nested completion blocks.
Phase 1: Audit Your Existing Concurrency Landscape
Don’t touch a single line of code until you’ve mapped every asynchronous boundary in your app. I mean it. In a sprawling codebase, hidden dependencies and silent threading assumptions will shred a naive migration.
Build a Dependency Graph of Async Functions
Start by hunting down every function that takes a completion handler, returns a Result type used asynchronously, or touches DispatchQueue or OperationQueue directly. I lean on swift-syntax to parse source files and spit out a candidate list. From there, I organize things into a spreadsheet—columns for module, file, function signature, and the concurrency mechanism at play. Keep your eyes peeled for functions that bridge to C or Objective-C. Those will demand custom isolation handling down the line.
For each async function, trace its callers and callees at least three levels out. You’ll uncover “async infection” chains: the paths where a synchronous function quietly schedules work on a background queue. Flag any function called from the main thread without explicit dispatching—these are your highest-risk spots because they assume a specific execution context that actors will upend.
Identify Shared Mutable State
Concurrency bugs live in shared mutable state. Scan your codebase for global variables, singleton properties, and class instances that get passed between queues. For each one, document how it’s currently protected: a serial queue, a lock, os_unfair_lock, or—and I’ve seen this too many times—absolutely nothing. This inventory feeds directly into your actor design. On one project, we found a cache object accessed from six different dispatch queues with patchy lock coverage at best. It was causing a crash every few thousand sessions, and nobody could ever reproduce it.

Phase 2: Establish a Swift Concurrency Foundation
You can’t flip a switch and migrate everything at once. Instead, build a slim compatibility layer so new concurrent code can coexist with the old callback-based stuff. This layer does two things: wraps old APIs in async interfaces, and gives you safe paths to call new actors from synchronous contexts.
Wrapping Completion Handlers with Continuations
For any function that uses a completion handler, cook up an async overload using withCheckedThrowingContinuation or withUnsafeThrowingContinuation. Start with the checked variant during migration—it’ll catch exactly-once continuation resume violations that signal a buggy handler. Park these wrappers in a dedicated file or module so you can delete them cleanly once the underlying implementation is migrated.
// Legacy function
func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) { ... }
// Async wrapper
func fetchUser(id: String) async throws -> User {
return try await withCheckedThrowingContinuation { continuation in
fetchUser(id: id) { result in
continuation.resume(with: result)
}
}
}
For delegate-based APIs, reach for AsyncStream or AsyncThrowingStream. A pattern I keep returning to: create a stream that buffers delegate callbacks, then iterate over it with for await. This shines for location updates, Bluetooth characteristics, or homegrown networking layers.
Defining Your First Actors
Take the shared mutable state you surfaced in Phase 1 and isolate it behind actors. Pick the simplest, most self-contained state first—maybe a cache, a session manager, or a database queue wrapper. Don’t rush to slap actors on everything. Each actor creates a new isolation domain, and sending values across domains means those values need to be Sendable. That constraint will ripple through your type system in ways you might not expect.
When you design an actor, think hard about which operations should be synchronous inside the actor’s context and which should be async. A method that returns a value right away without suspension should be synchronous; the actor’s serial executor still guarantees exclusive access. For methods that call other async functions, mark them async so the actor can suspend and resume safely.
Phase 3: Incremental Migration by Module
Flipping a compiler flag and staring down 3,000 errors at once is a great way to stall a pull request and sap team morale. Instead, migrate module by module, starting from the leaves of your dependency graph and working inward toward the main app target.
Start with Pure Data Layers
Network clients, persistence layers, and data transformers are usually the easiest modules to migrate. They have few dependencies and well-defined inputs and outputs. Convert their public interfaces to async throws, swap internal DispatchQueues for actors, and update your tests. For each module, aim for a state where no completion handlers leak across the module boundary.
This is where you’ll hit your first real Sendable conformance headaches. Value types (structs, enums) that contain only Sendable properties conform automatically. Classes need explicit conformance and careful isolation. If a class stores mutable state, you’ve got two options: protect it with a lock and mark it @unchecked Sendable (with clear docs on why it’s safe), or refactor it into an actor. I lean hard toward the actor approach; @unchecked is a temporary escape hatch, and you want it gone before the migration wraps up.
Move Up to Business Logic and View Models
Once your data layers speak async natively, the modules that consume them can adopt async/await without contortions. In an MVVM setup, methods that fire off network requests or database writes become async and get called inside Task blocks from the view layer. This is also where @MainActor starts pulling its weight to keep UI updates on the main thread.
Slap @MainActor on whole classes that are glued to the UI—view models, coordinators, view controllers. For individual methods or properties that must run on the main actor but live in a non-isolated type, use the attribute more surgically. Just know: calling a @MainActor method from a background context demands an await, and the compiler will hold you to it.

Phase 4: Testing Under Strict Concurrency Checking
Swift’s concurrency system is only as safe as the compiler’s ability to verify it. Throughout the migration, ratchet up the concurrency checking level gradually. Start with SWIFT_STRICT_CONCURRENCY = minimal to surface warnings about the obvious stuff, then move to targeted for specific modules, and finally complete when you’re confident a module is truly safe.
Write Tests That Exercise Isolation
Vanilla unit tests that call a function on the main thread won’t smoke out actor isolation bugs. You need tests that hammer actor methods from multiple tasks at once. Use Task groups to fire concurrent requests at an actor and verify its state stays coherent. For instance, if an actor manages a counter, spawn 100 tasks that each increment it and then assert the final count.
For @MainActor-annotated view models, write tests that use await MainActor.run to run assertions on the main actor. This proves your UI updates are properly isolated. You’ll probably need to spin up mock actors for dependencies to dodge real network calls during tests.
Use the Thread Sanitizer and Instruments
Even with strict concurrency checking flipped on, runtime gremlins can slip through—especially where non-Swift code is involved. Run your test suite under the Thread Sanitizer regularly. Hunt for data races on properties you thought were isolated, and watch for use-after-free issues in classes that cross isolation boundaries. The System Trace template in Instruments helps you visualize task scheduling and spot context switches you didn’t expect.
Common Pitfalls and How to Avoid Them
Every big migration runs into snags. Here are the ones I keep stepping into—and how to sidestep them.
Over-Isolating with Actors
Actors fix data-race headaches, but they add asynchronous overhead. Isolate a small, hotly accessed piece of state in an actor, and suddenly every read becomes an await—which can kneecap performance. For simple value-type state, think about using a struct with a lock or an atomic wrapper instead. Save actors for state that genuinely needs serialized access across multiple operations.
Forgetting to Propagate Cancellation
Swift concurrency leans on cooperative cancellation. You fire off a Task and cancel it later—the cancellation flag gets set, but your code has to check it. In long-running loops or recursive functions, pepper in Task.checkCancellation() or inspect Task.isCancelled periodically. When wrapping legacy code that knows nothing about cancellation with continuations, install a cancellation handler that calls the old cancellation mechanism.
Mixing Actors with the Main Thread Incorrectly
A mistake I see a lot: calling a @MainActor method from a non-isolated context without await. The compiler catches most of these, but with @preconcurrency imports (from modules that haven’t adopted strict checking yet), runtime issues can still bite. Be explicit about isolation: if a class is @MainActor, don’t dispatch to it from a background queue. Use await to hop onto the main actor cleanly.
FAQ
How long should a large-scale migration take?
The timeline hinges on codebase size and how comfortable the team is with the new model, but for a project with 200,000+ lines of Swift, plan on several months of phased work. The audit and foundation phases usually eat up a few weeks. Module-by-module migration can happen in parallel once the patterns settle. Fair warning: the last 10% of the codebase—the gnarly bits with complex threading or C interop—will take way longer than you’d think.
Can I use Swift Concurrency with iOS 13 or 14 deployment targets?
Yes, with some catches. Swift’s concurrency runtime is back-deployed to iOS 13, macOS 10.15, watchOS 6, and tvOS 13. But you can’t use async/await directly in code that has to compile against an older SDK without conditional compilation. Set your minimum deployment target correctly and use availability checks if you’re juggling older OS versions alongside newer ones.
What should I do about third-party libraries that haven’t adopted concurrency?
Write async wrappers around their callback-based APIs using continuations, as outlined in Phase 2. Corral these wrappers in a thin adapter layer. If the library does its own internal threading, be careful about which thread your continuation resumes on—you might need to dispatch back to a specific queue. File issues with the maintainers; plenty of them are actively adding concurrency support.
How do I handle Objective-C interop during migration?
Swift can sometimes import Objective-C completion-handler methods as async automatically—if they follow the naming convention and have a nullable error parameter. For methods that don’t, write manual wrappers. Objective-C classes aren’t actors, so calls from Swift concurrency contexts need extra care: you’re on the hook for thread safety on the Objective-C side. Lean on @MainActor for UIKit and AppKit classes that are inherently bound to the main thread.
Migrating a large codebase to Swift Concurrency is a slow, methodical grind that rewards patience. Audit everything, build a compatibility foundation, and migrate in small, testable slices. You’ll end up with a codebase that’s not just safer but easier to hold in your head. The compiler turns into an ally, catching concurrency slips at build time instead of letting them pop up as random crashes in production.