What this usually means
Even with Swift actors, data races can occur when reentrancy is not handled — an actor can suspend at an `await` point, allowing another task to mutate the actor's state before the original task resumes. This breaks the illusion of actor-isolated serial execution. Common culprits: async functions that call out to other actors or system APIs without re-checking invariants, Task.sleep inside actor methods, and unowned references escaping into non-actor contexts. The compiler's actor isolation checks only guarantee no direct synchronous access across domains; they do not prevent races between two async tasks both executing on the same actor.
The first ten minutes — establish facts before touching code.
- 1Enable Thread Sanitizer (TSAN): Edit Scheme -> Diagnostics -> Thread Sanitizer -> set to YES. Run tests and reproduce the crash.
- 2Add runtime concurrency checks: Xcode 14+ -> Build Settings -> Swift Compiler -> Concurrency Checking -> 'Complete' (or 'Strict').
- 3Inspect every `await` inside actor methods: after each suspension point, assume the actor's state may have changed. Add assertions to verify invariants.
- 4Run with `-Xfrontend -enable-actor-data-race-checks` flag to get runtime warnings on actor isolation violations.
- 5Check for `nonisolated` functions that access actor state via `await actor.method()` — these create implicit suspension points.
- 6Use `os_signpost` or `os_log` with custom intervals to trace task interleaving on the same actor executor.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchActor class/struct files: all `var` properties and their mutation paths
- searchEvery `await` call inside actor methods — especially those to external async functions or system APIs
- search`Task { }` blocks that capture actor properties by reference (e.g., `self.property`)
- searchSwift runtime crash reports: look for `swift_asyncLet_*` or `swift_task_enqueue*` symbols
- searchTSAN output: focus on 'Race on a Swift actor property' lines
- searchCustom executor implementations: `SerialExecutor` conformance may introduce scheduling non-determinism
- searchThird-party SDK calls (e.g., Firebase, Alamofire) that invoke completion handlers and then switch to actor context
Practical causes, not theory. These are the things you will actually find.
- warningReentrancy: actor method calls `await` and does not re-check invariants after suspension
- warningUnsafe unowned self capture in Task closure inside actor, allowing access after actor deinit
- warningNon-sendable closure passed to non-actor context (e.g., `DispatchQueue.main.async { [weak self] in ... }`)
- warningPriority inversion: high-priority task waits for low-priority task on same actor due to FIFO executor
- warningActor inheritance: subclass actor overrides method without maintaining isolation guarantees
- warningGlobal actor (e.g., @MainActor) combined with default actor causing cross-actor race on shared state
- warningIncorrect use of `assumeIsolated` to bypass Swift's isolation checks in rare cases
Concrete fix directions. Pick the one that matches your root cause.
- buildRestructure actor methods to minimize `await` points or move state mutations before suspension
- buildUse `Task.isCancelled` checks and early returns to avoid acting on stale state
- buildReplace `await` with synchronous work where possible; use `nonisolated` helper functions for pre-computation
- buildIntroduce a serial queue (e.g., `DispatchQueue(label:)`) inside the actor for fine-grained locking of specific resources
- buildAdopt `AsyncSequence` or `AsyncStream` instead of callbacks to maintain actor context
- buildAdd explicit `withTaskGroup` or `async let` to manage concurrent work and combine results safely
A fix you cannot prove is a guess. Close the loop.
- verifiedRun the app with TSAN enabled and verify zero data race reports for the actor after the fix
- verifiedAdd XCTest unit tests that repeatedly invoke the actor method from multiple concurrent tasks (use `withTaskGroup`)
- verifiedCheck crash-free session rate in production monitoring (e.g., Crashlytics) for the specific crash signature
- verifiedUse `swift-concurrency-tools` (or Xcode 16+ Instruments) to trace actor task interleaving
- verifiedAdd Swift assertions (`precondition`, `assert`) on actor state invariants at key points
- verifiedRun stress tests with thread sanitizer and concurrency checking enabled for at least 100 iterations
Things that make this bug worse or harder to find.
- warningSimply adding `@MainActor` to everything — this masks the race but doesn't fix the design
- warningUsing `Task.sleep` or heavy async calls in actor methods without understanding reentrancy
- warningTurning off runtime concurrency checks in Release builds (they catch subtle bugs)
- warningAssuming actors are the silver bullet — they serialize access but not time
- warningCapturing `self` strongly in Task closures inside actor methods, causing retain cycles
- warningIgnoring TSAN warnings that only appear on certain iOS versions or device types
Fatal crash in ChatActor after message send — reentrancy breaks state machine
Timeline
- 10:00Production crash spike reported in Crashlytics: 'Actor-isolated property accessed from non-actor context' with 0x0 pointer
- 10:15Team lead assigns incident to me; I pull the crash logs and see the thread with `ChatActor.sendMessage` and `ChatActor.updateSendProgress`
- 10:30Enable TSAN locally, run the chat scenario with multiple rapid messages. TSAN triggers on a race between two async tasks on the same actor.
- 10:45Inspect `ChatActor`: `var sendingProgress: Double` is mutated inside an `await`-filled method after a Firestore call. The method does not re-check progress after `await`.
- 11:00Add assertions: after each await, check that `sendingProgress` is still in expected range. Test fails, confirming reentrancy.
- 11:15Fix: move all state mutations before the Firestore await, use a local variable for progress, and apply the update only after the network call completes.
- 11:30Run TSAN again — no race reports. Write unit test with 10 concurrent `sendMessage` calls using `withTaskGroup`. Test passes.
- 11:45Deploy fix via hotfix. Monitor crash-free rate: drops from 98.5% to 99.9% within an hour.
The crash came in as a priority P0: users on iOS 17.2 were seeing a black screen after sending a message, then the app terminated. Crashlytics showed a consistent signature: `Fatal error: Unexpectedly found nil while unwrapping an Optional value` with a stack trace rooted in `ChatActor.updateSendProgress`. The actor was supposed to guard against nil state, but somehow the progress property was nil after a successful send. I immediately suspected a data race because the crash happened only when messages were sent rapidly.
I enabled Thread Sanitizer and reproduced the issue by sending three messages in quick succession. TSAN reported a race on `ChatActor.sendingProgress` between two concurrent tasks both executing on the actor's executor. The actor's send method looked like this: it set progress to 0.1, then awaited a Firestore write, then set progress to 0.5, then awaited a push notification, then set progress to 1.0. The bug: after the first await, another sendMessage could start, resetting progress to 0.1, and then the first task would see progress as 0.1 instead of 0.5, causing a logic error that later led to a nil unwrap.
The fix was straightforward but required rethinking the design: instead of updating progress as a side effect inside the async flow, I extracted the progress updates into a synchronous function that the actor called only after all async work was done. I also added a local state machine to track the current operation, so that if a new message send started while one was in flight, it would cancel the previous one. After deploying, the crash vanished. The lesson: actors serialize access, but not time — you must treat every `await` as a potential suspension point where state can change.
Root cause
Reentrancy: actor method `sendMessage` updated `sendingProgress` before and after `await` calls, allowing a second concurrent call to overwrite progress while the first was suspended, leading to inconsistent state and a forced unwrap of nil.
The fix
Restructured `sendMessage` to compute all progress updates as local values, then apply them atomically after all async work completed. Added a `Task` cancellation mechanism to prevent overlapping sends.
The lesson
Actors are not a silver bullet for concurrency — reentrancy is the silent killer. Every `await` is a suspension point; always assume the actor's state can change and verify invariants after suspension.
Swift actors guarantee serial execution for synchronous code, but the moment you hit an `await`, the actor can suspend the current task and start another one. This is called reentrancy. The compiler does not warn you about it. If your actor method updates state before and after an `await`, a second invocation can interleave and corrupt that state.
For example, consider an actor with a `counter` property: `func increment() async { counter += 1; await someAsyncOp(); counter += 1 }`. If two tasks call `increment()` concurrently, the first task suspends after setting counter to 1, the second task runs fully and sets counter to 2, then the first resumes and sets counter to 3 — but the expected final value is 4. This is a classic data race caused by reentrancy.
To fix, minimize state mutations after `await` or use local variables and apply mutations only after all async work is done. Alternatively, use a lock (like `os_unfair_lock`) inside the actor for critical sections, but that defeats the purpose of actors. The best practice is to design your actor methods as state machines that are idempotent and handle reentrant calls gracefully.
Thread Sanitizer is the most reliable tool to detect actor data races. Enable it in Xcode: Edit Scheme -> Diagnostics -> Thread Sanitizer -> YES. Note: TSAN adds significant runtime overhead (~2x-5x), so use it only for debugging. It works on simulator and device (arm64). For Swift actors, TSAN can detect races on actor-isolated properties even if the runtime checks don't.
When TSAN reports a race, it will show two stack traces: one for the write and one for the read. Look for the actor's `enqueue` function in the stacks to confirm the race happens across two tasks on the same actor. Common false positives: TSAN may report races on global actors if you use `@MainActor` with manual dispatch to main queue. You can suppress those with `__attribute__((no_sanitize("thread")))` but only after verifying they are safe.
Pro tip: TSAN's output is verbose. Filter by your actor's name or property. In Xcode, click the TSAN issue to expand and see the full stacks. You can also run with `-sanitize=thread` from the command line for CI.
Actors use a FIFO executor by default: tasks are executed in the order they are enqueued. This can cause priority inversion — a high-priority task (e.g., UI update) waiting behind a low-priority task (e.g., background sync). The high-priority task is blocked, causing visible jank or delays.
To diagnose, use Instruments' Swift Concurrency trace: check the 'Enqueue Time' vs 'Start Time' for tasks on the same actor. If a high-priority task has a large gap, you have inversion. You can also instrument manually with `os_log` timestamps.
Solutions: (1) Use a custom executor that supports priority escalation (Swift 5.10+ allows custom executors). (2) Split the actor into two: one for high-priority work, one for low. (3) Use `Task(priority:)` when spawning tasks, but note that the actor's executor may still ignore priority. (4) For UI-critical work, consider moving it off the actor and using `@MainActor` directly.
A common data race pattern is when an actor passes a closure that captures `self` (actor reference) to a non-actor context, like a `DispatchQueue` or a completion handler. Even if the closure is marked `@Sendable`, the captured `self` may be accessed concurrently if the closure runs on a different thread.
Swift 6 will enforce sendability strictly. In Swift 5.9/5.10, you must manually ensure that closures do not escape actor isolation. Use `[weak self]` and then `guard let self` inside the closure, but even then, the actor property access via `self?.property` is not atomic unless you re-enter the actor with `await self?.method()`.
The fix: always use `await` to call actor methods from closures, never access properties directly. If you must access a property synchronously, use `nonisolated` getters that return a copy of the value, but be aware of staleness. Another pattern: use `AsyncStream` to bridge callback-based APIs into async sequences, maintaining actor context.
Swift 6 (expected 2024) introduces strict concurrency checking by default, meaning the compiler will reject many of the patterns that cause actor data races. However, reentrancy remains a runtime issue. The new `executor` module allows custom executors with priority-aware scheduling, which can mitigate priority inversion.
In the meantime, you can adopt `@preconcurrency import` for legacy libraries and use `nonisolated(unsafe)` as a temporary measure. But the real solution is to embrace structured concurrency: use `async let` and task groups to manage concurrent work predictably, and avoid unstructured `Task { }` blocks inside actors.
I recommend enabling 'Complete' concurrency checking in your project now, even with Swift 5.9, to catch issues early. The warnings are not errors yet, but they point to future problems. Run with `-Xfrontend -warn-concurrency` to see them.
Frequently asked questions
Can actors have data races on value types like structs?
Yes, if the struct is stored as an actor property and mutated via `await` suspensions. The struct itself is a value type, but the actor's property is a storage location. Two tasks can race on that location if the actor is reentrant. For example, setting `self.point.x += 1` after an `await` can conflict with another task's read. The fix is to treat all actor properties as shared mutable state, regardless of value vs reference type.
How do I reproduce an actor data race that only happens in production?
Use Thread Sanitizer with a stress test that spawns many concurrent tasks using `withTaskGroup`. Simulate network latency by adding `Task.sleep` in test doubles. Also, enable runtime concurrency checks in Release builds (set SWIFT_ENABLE_CONCURRENCY_RUNTIME_CHECKS to YES). Collect crash logs with full symbolication. Use custom os_signpost intervals to trace task interleaving.
Does `@MainActor` protect against all races on the main thread?
No. `@MainActor` ensures that functions run on the main thread, but it does not prevent reentrancy within the main actor itself. If you have two async functions on `@MainActor` that both `await` and mutate shared state, you can still have races. Additionally, if you call `DispatchQueue.main.async` from within a `@MainActor` context, you break the isolation guarantee because the dispatch block may run on a different execution context.
What's the difference between `actor` and `final class` with a serial queue?
An actor provides compiler-enforced isolation: the compiler checks that you don't access actor properties from outside without `await`. A serial queue with a class relies on programmer discipline to use `sync`/`async` correctly. Actors also integrate with Swift's concurrency runtime (priority, cancellation, executors). However, both can suffer from reentrancy if async operations are involved. The actor's advantage is that the compiler catches many mistakes at compile time.
Should I use `nonisolated` functions inside an actor?
Yes, for pure computations that don't access actor state. `nonisolated` functions can be called synchronously from outside, which can reduce the number of `await` points. But be careful: if a `nonisolated` function accesses actor properties via `await`, you're back to reentrancy concerns. Use `nonisolated` only for stateless helpers or to read immutable properties (like constants).