Skip to content

Recommended Reading

How to Migrate Legacy Swift Code to Modern Concurrency Without A ‘Big Bang’ Rewrite

8 Minutes

How to Migrate Legacy Swift Code to Modern Concurrency Without A ‘Big Bang’ Rewrite

Fix Bugs Faster! Log Collection Made Easy

Get started

Concurrency means running multiple tasks at the same time, and it’s a great way to ensure our apps stay responsive.

Swift 5.5 introduced structured concurrency and the closely related concept of async/await to improve the management of asynchronous code, part of a wave of changes designed to ensure simpler code, improved error handling and automatic task lifecycle management.

However, many of us (most likely all of us, in fact) have been using earlier versions of Swift, which means we’ve been using older concurrency techniques.

So, how do we migrate all our code without the dreaded ‘big bang’ rewrite, which drains our time and can sacrifice all those hard-won bug fixes?

Well good news: we don’t need a 100% overhaul.

In fact we can migrate step by step, and this article will show you how, with Before → After examples of each migration step.

How to migrate legacy Swift code to modern concurrency: step by step

1. Audit what you have

Before migrating, we need to take stock of the legacy patterns in our codebase. A quick audit will help show us where to focus.

Here’s a recap of the most common items used to achieve concurrency before the roll-out of Swift 5.5, with their specific use cases:

  • Callbacks and completion handlers often wrap network or file I/O.
  • DispatchQueue show background tasks, timers, or thread hops.
  • DispatchGroup provide fan-out/fan-in logic to wait for multiple tasks.
  • Semaphores or NSLock protect the shared mutable state.
  • OperationQueue ensure higher-level concurrency with dependencies.

In each case, we mark whether it’s handling I/O, CPU work, or UI updates. This gives us a map of what to modernize first.

2. Wrapping callbacks with continuations

When we find a function that uses a callback, we don’t simply throw it away.

Instead, we add a new async wrapper around the function with withCheckedContinuation. This lets our code call the function with await, while the old version will still serve anything that depends on it.

Here’s how to ensure a step-by-step migration:

  • Keep the old callback function. Don’t break any existing code — yet.
  • Add a new async wrapper using a continuation.
  • Start updating call sites to use the new async version with await.
  • Remove the old callback only when nothing calls it anymore.

Pro Tip: if the callback also passes errors, use withCheckedThrowingContinuation.

// 👴 Old code we already have
func load(_ done: @escaping (String) -> Void) {
    DispatchQueue.global().async {
        done("OK")
    }
}

// 🚀 New async wrapper we add
func loadAsync() async -> String {
    await withCheckedContinuation { cont in
        load { cont.resume(returning: $0) }
    }
}

// ✅ How we now use it
Task {
    let result = await loadAsync()
    print(result) // "OK"
}

3. Replacing DispatchGroup with Task Groups

Previously, we used DispatchGroup to group multiple asynchronous tasks together and give them time to finish before continuing. With Swift concurrency, we replace it with withTaskGroup or async let, which are safer and easier to read. Again, there’s some stuff to bear in mind if we want to ensure a step-by-step migration:

  • Keep the old DispatchGroup code if other parts of the app depend on it.
  • Add a new async function that uses withTaskGroup (or async let for a small, fixed set).
  • Start updating call sites to use the new async version.
  • Remove the old group code once it’s no longer used.
// 👴 Old code we already have
let group = DispatchGroup()
var a = 0, b = 0

group.enter()
DispatchQueue.global().async { a = 1; group.leave() }

group.enter()
DispatchQueue.global().async { b = 2; group.leave() }

group.notify(queue: .main) {
    print(a + b) // 3
}

// 🚀 New async version we add
func fetchSum() async -> Int {
    await withTaskGroup(of: Int.self) { group in
        group.addTask { 1 }
        group.addTask { 2 }
        return await group.reduce(0, +)
    }
}

// ✅ How we now use it
Task {
    let total = await fetchSum()
    print(total) // 3
}

4. Replacing DispatchQueue.async with task

DispatchQueue.async was the default way to kick work to a background queue. With Swift concurrency, we use Task {} instead—scoped, cancelable, and easier to reason with.

  • Keep the old GCD call if other code still uses it.
  • Add a new async path using Task {} (and MainActor for UI).
  • Start updating call sites to call the new version.
  • Remove the GCD usage once nothing depends on it.

Tip: prefer await MainActor.run for UI updates instead of DispatchQueue.main.async.

// 👴 Old code we already have
DispatchQueue.global().async {
    // Background work
    let value = "OK"
    DispatchQueue.main.async {                      // UI hop
        label.text = value
    }
}

// 🚀 New async version we add
func doWorkAsync() {
    Task {
        let value = "OK"                            // Background by default
        await MainActor.run { label.text = value }  // UI hop with MainActor
    }
}

// ✅ How we now use it
doWorkAsync()

5. Moving UI updates to MainActor

Remember: UI must always be updated on the main thread. In legacy code, we did this with DispatchQueue.main.async. With Swift concurrency, we mark code with @MainActor or use await MainActor.run, which makes intent clearer and safer.

  • Keep the old main-queue hop if it’s still in use.
  • Add a new async path that uses MainActor instead.
  • Start updating call sites to call the new version.
  • Remove the old dispatch call once everything uses MainActor.

Tip: use @MainActor on view models or classes that always touch UI.

// 👴 Old code we already have
DispatchQueue.main.async {
    label.text = "Hello"
}

// 🚀 New async version we add
await MainActor.run {
    label.text = "Hello"
}

// ✅ How we now use it
@MainActor final class AppModel {
    var text = ""
    func update(_ newText: String) { text = newText }
}

6. Replacing Locks and Semaphores with Actors

Locks and semaphores fix races but invite deadlocks and leaks. Actors isolate state by design: one accessor at a time, no manual locking, safer by default.

  • Keep the old lock/semaphore code while other parts still depend on it.
  • Add an actor that owns the shared state and exposes async methods.
  • Start updating call sites to call the actor with await.
  • Remove the locks/semaphores once no code uses them.

Tip: keep actor APIs small and focused (getters/setters or intent-specific methods).

// 👴 Before: shared state with a lock
final class Counter {
    private var n = 0
    private let lock = NSLock()

    func inc() {
        lock.lock(); n += 1; lock.unlock()
    }
    func value() -> Int {
        lock.lock(); defer { lock.unlock() }
        return n
    }
}
// 🚀 After: actor isolation (no locks needed)
actor CounterActor {
    private var n = 0
    func inc() { n += 1 }
    func value() -> Int { n }
}
// ✅ Usage: migrate call sites
let counter = CounterActor()
await counter.inc()
let current = await counter.value()

Common pitfalls

Migrating to Swift concurrency is way smoother with async/await, but there are still some traps we should watch out for. Many come from mixing old and new patterns or misusing continuations.

  • Double resume in continuations. Always ensure a continuation is resumed once and once only.
  • UI not updating. Keep an eye out for a missing @MainActor and don’t forget to hop back to the main actor.
  • Capture warnings. Non-Sendable values inside async closures can cause issues. Be sure to mark closures @Sendable or move updates to MainActor.
  • Deadlocks with semaphores/locks. If old code sneaks in, replace it with actors or await.

Catching these early will help prevent subtle bugs and make the migration more reliable.

Swift concurrency migration summary

Migrating from legacy concurrency to Swift’s modern async/await system doesn’t require a rewrite overnight. By wrapping callbacks, replacing groups and queues with structured tasks, moving UI updates to MainActor, and isolating state with actors, we modernize step by step. The result: cleaner code, safer execution, and fewer bugs.

Legacy ApproachModern Swift Concurrency
Callbacks / completion handlersasync/await with withCheckedContinuation
DispatchQueue.asyncTask {} + await MainActor.run for UI
DispatchGroupwithTaskGroup or async let
NSLock / semaphoresactor isolation
OperationQueueTask, async let, or task groups

This way, we incrementally replace fragile patterns with structured concurrency. Happy coding!

Expect The Unexpected!

Debug Faster With Bugfender

Start for Free
blog author

Aleix Ventayol

Aleix Ventayol is CEO and co-founder of Bugfender, with 20 years' experience building apps and solutions for clients like AVG, Qustodio, Primavera Sound and Levi's. As a former CTO and full-stack developer, Aleix is passionate about building tools that solve the real problems of app development and help teams build better software.

Join thousands of developers
and start fixing bugs faster than ever.