Recommended Reading
8 Minutes
How to Migrate Legacy Swift Code to Modern Concurrency Without A ‘Big Bang’ Rewrite
Fix Bugs Faster! Log Collection Made Easy
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.
Table of Contents
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(orasync letfor 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 {}(andMainActorfor 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
MainActorinstead. - 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
@MainActorand don’t forget to hop back to the main actor. - Capture warnings. Non-
Sendablevalues inside async closures can cause issues. Be sure to mark closures@Sendableor move updates toMainActor. - 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 Approach | Modern Swift Concurrency |
|---|---|
| Callbacks / completion handlers | async/await with withCheckedContinuation |
DispatchQueue.async | Task {} + await MainActor.run for UI |
DispatchGroup | withTaskGroup or async let |
NSLock / semaphores | actor isolation |
OperationQueue | Task, async let, or task groups |
This way, we incrementally replace fragile patterns with structured concurrency. Happy coding!
Expect The Unexpected!
Debug Faster With Bugfender