How to Migrate a Large Codebase to Swift Concurrency

Developer working on Swift code migration across two displays

Moving a big iOS or macOS codebase into Swift’s modern concurrency model is not a light switch you flip over a weekend. The jump from completion handlers, DispatchQueue chains, and Operation subclasses to async/await, tasks, and actors takes a plan. I’m Yuki Tanaka, and I’ve walked several teams through this. You need a real inventory of your existing concurrency patterns, a phased strategy, and testing that digs way past happy-path checks. This article lays out the concrete steps I’ve come to rely on, pulled directly from trenches on real projects.

Assessing Your Current Concurrency Landscape

Before you touch a single line, map every concurrency pattern that already lives in the repo. Start with static analysis or a plain old grep for completion handler signatures, DispatchQueue.main.async calls, DispatchGroup usage, and custom operation subclasses. The public API surface deserves your attention first — those boundaries determine how modules talk to each other. Note which patterns show up everywhere and which ones are buried under layers of nested calls. These observations will set your migration order.

For anything beyond a toy project, I tell teams to build a spreadsheet: file path, concurrency mechanism, any thread-safety annotations, and whether the code has unit tests. That sheet becomes your migration backlog. On one project with north of 2,000 completion-handler-based functions, we spent two full weeks just on the audit. Honestly, it felt slow at the time, but it saved us from landmines like hidden DispatchSemaphore blocks that don’t map cleanly to async code.

Identifying Actor Candidates

Keep an eye out for classes that hold mutable state and protect themselves with serial queues or locks. Those are your actor candidates. An actor gives you exclusive access to its state at compile time — no more manual synchronization for data-race prevention. Flag any class where a single DispatchQueue serializes every property access. Also watch for reference types that ship state across threads through callbacks. Those often benefit from being redesigned around an actor.

Phased Migration Strategy

Don’t try a big-bang rewrite. The risk of slipping in subtle concurrency bugs is too real. Instead, go leaf-to-root: start with the tiniest, most isolated pieces and work upward toward the app layer. Utility functions, data formatters, network parsers — convert those before you ever touch a view model or controller.

Phase 1: Add Async Wrappers

Begin by giving existing completion-handler APIs an async sibling. Use withCheckedThrowingContinuation to bridge old code without rewriting internals yet. Here’s the pattern:

func fetchUser(id: String) async throws -> User {
    return try await withCheckedThrowingContinuation { continuation in
        fetchUser(id: id) { result in
            continuation.resume(with: result)
        }
    }
}

This lets callers adopt async/await right away while the legacy guts stay put. Mark the old completion-handler version as deprecated — it signals intent clearly. Run your full test suite after each wrapper lands. Cancellation needs a sharp look here: withCheckedThrowingContinuation doesn’t handle cancellation for you, so store a cancellation handler if the underlying task supports it.

Code editor showing Swift async function bridging patterns

Phase 2: Convert Internal Implementations

Once every caller of a component uses the async wrapper, go after the internals. Replace nested completion handlers with straight-line async/await code. Toss the manual dispatch queue switching; lean on actors or the Task API for context changes. Where you used to have three or four callbacks stacked like plates, you’ll now have a single function body with obvious sequential logic. Readability jumps, and those indentation bugs just vanish.

During this phase, be relentless about Sendable conformance. The compiler will flag types that cross concurrency domains without it. Fix those warnings early — mark simple value types as Sendable or convert classes to actors. In one migration, we found a shared mutable dictionary getting accessed from multiple threads with zero synchronization. The Sendable check caught it at compile time, and that alone probably saved us a week of debugging.

Phase 3: Adopt Structured Concurrency

With the foundation solid, bring in task groups and async let bindings wherever you used DispatchGroup or operation dependencies. Structured concurrency makes lifetime explicit: child tasks finish before the parent scope exits. Take a pattern like this:

let group = DispatchGroup()
group.enter()
fetchA { resultA in
    group.leave()
}
group.enter()
fetchB { resultB in
    group.leave()
}
group.notify(queue: .main) {
    // combine results
}

And turn it into:

async let resultA = fetchA()
async let resultB = fetchB()
let combined = await (resultA, resultB)

You can’t forget a leave() call if it doesn’t exist. The flow sits right in front of you. For dynamic parallelism, withTaskGroup handles it cleanly. Fetching a list of items concurrently shrinks to a few lines that are easy to scan.

Handling Main Actor Isolation

UI code belongs on the main actor. Annotate view models and view controllers with @MainActor and let the compiler enforce it. This replaces scattered DispatchQueue.main.async calls that often slip past main-thread assertions. When a function is @MainActor, any call from a non-main context now demands an await — the context switch becomes something you can see while reading the code.

Be careful slapping @MainActor onto huge classes. If a class has thirty methods but only four touch the UI, consider pulling those four into a smaller, main-actor-isolated component. Over-annotating pushes unnecessary main-thread hops that hurt performance. Instruments and the Main Thread Checker are your friends — lean on them to confirm your choices.

Testing the Migration

Concurrency bugs are slippery. Your existing unit tests probably never stressed thread safety. Add tests that hammer components under heavy concurrency using Task and withTaskGroup. XCTest now supports async tests, so you can await results directly. For stress testing, spin up hundreds of concurrent tasks that read and write shared state — now guarded by actors — and check that consistency holds.

Turn on the Thread Sanitizer in CI. TSan catches data races at runtime, including the ones Sendable checks miss, like races through unsafe pointers or old Objective-C code. Keep TSan running for at least a few weeks after your migration wraps. Ongoing development will keep introducing changes, and you want to catch regressions early.

Test results dashboard with thread sanitizer warnings highlighted

Common Pitfalls and Specific Fixes

Overusing unstructured tasks: Don’t treat Task { } as a drop-in for DispatchQueue.async. Unstructured tasks can outlive their originating scope, leaking work and ignoring cancellation. Stick with async let or task groups whenever you can. Reserve Task { } for bridging from synchronous to asynchronous worlds — inside an @IBAction or a Combine pipeline, for instance.

Ignoring cancellation: Completion-handler code often cancels implicitly — you just stop calling the handler. Async/await expects you to check Task.isCancelled or call try Task.checkCancellation() at the right spots. Wrap long-running loops or multi-step operations with cancellation checks. Skip this, and a cancelled task keeps burning resources for nothing.

Blocking the cooperative thread pool: The async/await thread pool is tuned for short, non-blocking work. Call a blocking API — a sleep or a synchronous file read — inside an async function, and you starve the pool. At worst, you deadlock the app. Use async alternatives or shunt blocking work to a dispatch queue with Task.detached and a defined priority.

Incremental Rollout and Monitoring

Ship the migration in small, testable chunks. Each pull request should convert a handful of related functions — wrapper and internal rewrite together if you can manage it. After every release, watch crash rates and performance metrics. Spikes in hang rate or main-thread blocking often point to accidental main-actor contention.

Logging helps you track adoption. Count how many completion-handler functions remain and graph that number over time. The visual keeps the team motivated and highlights stale corners. From what I’ve seen, a 150,000-line codebase can be fully migrated in three to four months with two dedicated engineers, assuming you have reasonable test coverage to back you up.

FAQ

Should I migrate Objective-C code to Swift before adopting concurrency?

Not strictly necessary. You can add async wrappers in Swift that bridge to Objective-C completion-handler methods through withCheckedThrowingContinuation. But actors and structured concurrency are Swift-only. If your long-term direction is a modernized codebase, prioritize rewriting heavily concurrent Objective-C classes in Swift first. That’s where the payoff sits.

How do I handle third-party libraries that still use completion handlers?

Write a thin async wrapper layer, just like you do for your own legacy code. Keep the wrappers in a single file per library — easy to delete once the library updates. If a library exposes a delegate-based API, consider AsyncStream to turn delegate callbacks into an asynchronous sequence.

What is the performance impact of switching to async/await?

Usually negligible. The compiler optimizes async functions into efficient continuations. You might even see slightly better responsiveness because the cooperative thread pool cuts down on excessive thread context switches. Deeply nested async calls can increase stack frame pressure, though I’ve rarely seen this cause real trouble. Profile the hot paths specific to your app.

Can I mix Swift Concurrency with Combine?

Yes. Use the values property on a publisher (iOS 15+) to bridge a Combine publisher to an async sequence. For the reverse, wrap an async operation in a Future to get a Combine publisher. This lets you adopt piece by piece without rewriting entire reactive pipelines at once.