How to Debug Swift Concurrency Issues Systematically
Swift concurrency rewrites the rules for async code, and sure—it eliminates whole categories of bugs—but it also hands you a fresh set of headaches that can confuse even people who’ve been writing Swift for years. An actor that switches contexts when you least expect it, a task group that quietly cancels half its children, a data race that only flickers in the Thread Sanitizer report. You don’t fix these by guessing. You fix them by slowing down and following a system. Here’s the one I’ve built from too many late nights staring at stack traces.
1. Understand the Runtime Shape Before You Fix Anything
I’ve wasted whole mornings trying to patch a bug that existed only because I misunderstood who was running what. The runtime picture almost never matches the clean structure you hold in your head. So before you touch a breakpoint, map it out. Scribble the task tree on paper or in a scratch file. Note every Task, every Task.detached, and which actor or global executor owns each chunk of work. Mark down which isolation domain each piece of shared state belongs to.
Three questions to pin on the wall:
- Which actor—or the global executor—is calling this function?
- Where’s the next suspension point that might toss execution to a different context?
- Is there a task group hiding somewhere that could cancel without a peep?
If you can’t answer all three, stop. Ten minutes with a whiteboard will save you hours of placing breakpoints at random.

2. Enable Runtime Checks and Let the Compiler Guide You
Swift’s type system catches a lot at compile time, but the runtime checks catch what slips through. Flip on Thread Sanitizer and Main Thread Checker in your scheme settings. Then add the compiler flag -Xfrontend -enable-actor-data-race-checks. That flag instruments actor boundaries and traps the exact moment a non-sendable type sneaks across an isolation domain.
Every purple runtime warning is a real bug—even if your app hasn’t crashed yet. I’ve audited shipping apps with hundreds of these warnings sitting ignored. Each one is a latent crash waiting for the right timing. Fix them in the order they appear; later warnings are often just fallout from earlier ones.
2.1 Interpreting a Data-Race Warning
When Thread Sanitizer flags a race, it gives you two stack traces: one for the first access (usually on the main thread or inside an actor), and one for the conflicting access. Look for a shared mutable property that nobody’s guarding. The fix almost always lands in one of three buckets:
- Wrap the property in an
actorand access it throughawait. - Make the type
Sendableand pass it by value so each context gets its own copy. - Use an
OSAllocatedUnfairLockor a serialdispatch_queuewhen you can’t use an actor.
Stay away from @unchecked Sendable unless you can prove—really prove—the type is race-free. That annotation is a handshake with the compiler. Breaking it is worse than leaving it off altogether.

3. Isolate the Suspension Point with Strategic Logging
A task that hangs is almost always stuck on an await that never finishes. Breakpoints inside async functions turn into a mess because threads hop around. I skip them and add structured logging around every suspension point on the path instead.
Something like this:
func fetchAndProcess() async throws -> Data {
logger.debug("Entering fetchAndProcess on \(Task.currentPriority)")
let raw = try await network.fetch()
logger.debug("Network fetch completed, processing on \(Task.currentPriority)")
return try await process(raw)
}
Trigger the hang, open the logs, and find the last line that printed. That’s your stuck suspension point. Now you can dig into whether the callee is deadlocked, cancelled, or just waiting on a slow operation with no deadline.
3.1 Adding Timeouts to Every Async Call
Swift won’t time out an await for you. A network call that hangs can suspend your entire task tree indefinitely. I wrap external calls in a withTimeout helper that leans on task cancellation:
func withTimeout<T>(_ seconds: Duration, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: seconds)
throw TimeoutError()
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
Use this for network, file I/O, and any inter-process communication. It keeps one slow dependency from starving everything else.
4. Debug Task Hierarchy Leaks with the Memory Graph
A task that never finishes holds onto its captured references. If your view model refuses to deinit, a leaked child task is often the culprit. Open Xcode’s Debug Memory Graph and filter on Task plus your own types. Look for objects you expected to disappear. The backtrace on each leaked object tells you which task is gripping it.
Typical causes:
- A
Task { }closure grabsselfstrongly without a weak reference. - A
for-await-inloop on anAsyncStreamthat never ends. - A
withTaskGroupthat launches more tasks than it collects.
Break the strong reference cycle with [weak self], and give every stream a way out—like a try await Task.checkCancellation() inside the loop.

5. Reproduce with Deterministic Scheduling
Intermittent concurrency bugs are brutal because they depend on thread timing. To make them show up on command, you have to take control of the scheduler. Xcode 15’s Concurrency Instrument visualizes task execution, but for unit tests I prefer a custom global actor backed by a serial executor.
Define a @globalActor that uses a serial DispatchQueue. In tests, swap the real actor for this deterministic one. Now all async operations run in a predictable order, and you can surface race conditions that only appear under specific interleavings.
@globalActor actor TestActor {
static let shared = TestActor()
static let executor = SerialExecutor()
}
Write tests that drive the system through every interleaving you can think of. That’s the only way to be sure your fix hit the root cause and didn’t just hide the symptom.
5.1 Using the Swift Testing Framework for Concurrency
Swift Testing’s @Test macro supports async tests out of the box. Pair it with confirmation() to check that expected tasks actually finish. For example:
@Test func testDataProcessing() async throws {
await confirmation(expectedCount: 1) { confirmed in
Task {
let result = await processor.process()
#expect(result != nil)
confirmed()
}
}
}
This pattern keeps your test from passing just because the function returned before the async work completed.
6. Build a Debugging Checklist
When the pressure’s on, a checklist stops you from skipping steps. I keep this taped next to my screen:
- Enable Thread Sanitizer and actor data-race checks. Run the app.
- Reproduce the bug with deterministic scheduling.
- Log every suspension point. Find the one that hangs.
- Check the memory graph for leaked tasks.
- Verify every
Sendableconformance is correct. - Add timeouts to all external async calls.
- Write a test that fails before the fix and passes after.
Follow it every time. The discipline strips the emotion out of debugging and turns a chaotic scramble into a set of small, testable experiments.
FAQ
Q: Why does my actor method still cause a data race warning even though actors are supposed to be safe?
A: An actor shields its own mutable state, but if you pass a non-Sendable reference into or out of the actor, other code can mutate that reference concurrently. The warning targets the shared reference, not the actor. Mark the parameter types as Sendable or copy the data before it crosses the boundary.
Q: What’s the difference between a hang and a deadlock in Swift concurrency?
A: A hang happens when a task waits on something that never finishes—a network call with no timeout, for instance. A deadlock is when two or more tasks wait on each other in a cycle, often through actor reentrancy. Deadlocks freeze the cooperative thread pool; hangs block only the affected task tree. The Concurrency Instrument helps you tell them apart.
Q: How do I debug a withTaskGroup that never finishes?
A: A task group finishes when all child tasks complete and you’ve consumed every result. If you add tasks in a loop but skip awaiting them, or a child task hangs, the group hangs. Add a cancellation handler inside each child task, and loop over group.next() until it returns nil. Make sure the number of added tasks matches the number of consumed results.
Q: Can I use print() for concurrency debugging, or do I need os_log?
A: print() isn’t thread-safe and can reorder output under concurrency. Use Logger from os.log for structured, timestamped logs that preserve order. If you’re stuck, print() on a serial queue is better than nothing, but don’t trust it to diagnose race conditions.