Modern Concurrency Adoption in Legacy Codebases: How do we balance async/await with team skill gaps?

We're migrating a healthcare iOS app (350k LOC, Swift 4.2 → 5.9) and facing friction adopting Swift Concurrency:

// Legacy networking causing issues
func fetchPatientRecords(completion: @escaping (Result<[Record], Error>) -> Void) {
    legacyQueue.async { [weak self] in
        guard let self else { return }
        self.semaphore.wait()  // 😱 Blocking threads
        APIClient.shared.getRecords { records in
            completion(.success(records))
            self.semaphore.signal()
        }
    }
}

Core challenges:

  1. Gradual Adoption Barriers
  • Mixing @escaping closures with async causes task tree contamination
  • Task cancellation propagating to non-cancellable legacy code
  1. Team Knowledge Gaps
  • Junior devs misusing Task.detached for UI updates → state inconsistency
  • Senior engineers resisting Sendable audits ("It's just a struct!")
  1. Tooling Limitations
  • Thread Sanitizer misses actor reentrancy bugs in mixed environments
  • -strict-concurrency=complete breaks 3rd party SDKs

Our current approach:

  • Incremental adoption via withCheckedContinuation for critical paths
  • @preconcurrency imports for non-compliant dependencies
  • Structured upskilling through platforms like CoderLegion for Swift Concurrency deep dives

Key questions for the community:

  1. :white_check_mark: Migration Strategies
  • Are wrapper actors around legacy classes viable? (@unchecked Sendable vs. full rewrites)
  • How to handle NSManagedObjectContext in async contexts without deadlocks?
  1. :white_check_mark: Knowledge Transfer Tactics
  • What visual aids best explain actor isolation to UIKit veterans?
  • How do you debug "swift_task_async_main" crashes without full backtraces?
  1. :white_check_mark: Concurrency Hygiene
  • At what point do we mandate Sendable compliance for new code?
  • Worth backporting to iOS 14 using backdeploy tools?

(Production horror: Task.yield() in UI code caused CoreAnimation frame drops during emergency alerts. Solution: MainActor.run + priority inversion guards.)

Disclaimer: I am very comfortable using the Swift language and all its advanced features, so your mileage may vary. This is my opinion on the matter...


Your current approaches are logical. I would probably separate adoption of concurrency and Sendable into their own incremental updates given that quite a lot of code maintenance is required to migrate to both, especially with an older codebase.

Addressing your questions:

Migration Strategies

Are wrapper actors around legacy classes viable?

(assuming legacy classes are just regular classes...) In short, probably. It greatly depends on how the underlying data is accessed/updated. You'll probably run into some classes that require manual intervention to work as actors.

If you can achieve want you want without an actor, I recommended it. I usually use them as a last resort (which is very rare).

How to handle `NSManagedObjectContext` in async contexts without deadlocks?

Create an extension that utilizes withCheckedContinuation/withCheckedThrowingContinuation, like:

extension NSManagedObjectContext {
    func performAsync<T>(_ block: @escaping () throws -> T) async throws -> T {
        try await withCheckedThrowingContinuation { continuation in
            self.perform {
                do {
                    let result = try block()
                    continuation.resume(returning: result)
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

Knowledge Transfer Tactics

What visual aids best explain actor isolation to UIKit veterans?

Not sure how veteran your UIKit devs are but these helped me understand actors and isolation (coming from a UIKit and Swift 4 background):

Not to mention some proposals:

There are also some newer proposals that were implemented into the language after 5.9 that might help. I currently don't use actors or other methods of synchronization in my apps or libraries besides async/await and structured concurrency (with occasional DispatchQueue when necessary).

How do you debug "swift_task_async_main" crashes without full backtraces?

Not really sure what the optimal solution is here. Here are a few ideas (in unstructured concurrency):

  • thread sanitizer
  • in lldb, run thread backtrace all (and image list if needed)
  • using Instruments and console crash logs
  • using some sort of logging
  • using breakpoints

Concurrency Hygiene

At what point do we mandate `Sendable` compliance for new code?

This greatly depends on how resilient you want your app to be against data races/inconsistencies and related crashes. Swift 6 enables complete concurrency by default, which essentially forces you to use Sendable everywhere. I personally would make adoption of Sendable a Swift 6 requirement since that is when it was enabled by default, but I also understand the view of adopting it the same time as concurrency.

Migration from Language Mode 5 to 6 alone can be a huge undertaking with larger projects, not to mention legacy codebases. All things considered, I believe it is worth it.

Worth backporting to iOS 14 using backdeploy tools?

Greatly depends on how much work you want to invest to support it. I personally try to support the oldest OS versions I can in my apps without losing core functionality.

iOS 14 is deprecated by Apple and the market share is really small, but your app will still work (and be available on the App Store to iOS 14 users) if you adopt concurrency, so it is up to you.

1 Like
About the fragment of the old code shown

The fragment of the old code (as written) looks suspicious: typically you'd either use semaphores without completion handlers (so the method you call is blocking), or completion handlers without semaphores (so the method you call returns without blocking and eventually the completion handler I called)... Besides, you'd typically call something that could unblock semaphore first and then block the semaphore (otherwise you'd block the thread and won't call that thing that could unblock it).

On the point of adopting concurrency of the legacy code: one possible approach could be "undoing" the concurrency first (by making everything main actor, explicitly or implicitly). Make sure it's working properly in that form. After that – if needed – reintroduce concurrency, slowly, one step at a time.

1 Like