Choosing Swift Actors Over DispatchQueue for Thread-Safe State
Use Swift actors for mutable shared state in new code targeting iOS 15+/macOS 12+. Actors provide compile-time data-race isolation; DispatchQueue only serializes at runtime and deadlocks easily. Includes decision criteria, comparison table, trade-offs, and a verified Counter actor implementation.
23 Jul 2026, 00:03 UTC

The Decision
For new Swift code targeting iOS 15, macOS 12, or later (Swift 5.5+), use actors with async/await to protect mutable shared state. Reserve DispatchQueue for bridging legacy callbacks, C APIs, or simple fire-and-forget work that doesn't share mutable data. Actors give you compile-time data-race isolation; DispatchQueue only provides runtime serialization and is easier to deadlock.
Constraints Before You Start
- Deployment target: iOS 15 / macOS 12 / tvOS 15 / watchOS 8 or newer — the Swift concurrency runtime is not available on older OS versions.
- Call sites must be async: Any code that reads or writes actor-isolated state must run in an
asynccontext (inside aTask, another actor method, or anasyncfunction). - No synchronous escape: Do not expose actor-isolated state through
nonisolatedsynchronous properties or closures that captureselfwithoutawait.
Supported Options Compared
| Mechanism | Isolation Enforcement | Cancellation | Deadlock Risk | Typical Use Case |
|---|---|---|---|---|
| Actor | Compile-time (data-race safety) | Structured, automatic via Task | Low (no blocking waits) | Mutable shared state in new code |
| DispatchQueue | Runtime only (no compiler help) | Manual (DispatchWorkItem) | High (sync waits on same queue) | Legacy callbacks, C APIs, fire-and-forget |
| NSLock / os_unfair_lock | None (manual discipline) | None | High (priority inversion, forgotten unlock) | Rare; only when async is impossible |
Trade-offs in Practice
Actors Require Async Refactoring
Adopting actors means callers must become async. A synchronous API like func getCount() -> Int becomes func getCount() async -> Int. This ripples upward: view controllers, view models, and network layers all need await. The payoff is the compiler catches data races before they ship.
DispatchQueue Preserves Synchronous Call Sites
Wrapping state in a serial DispatchQueue lets you keep synchronous getters and setters. But you lose compile-time guarantees — a missed queue.sync or a captured reference escapes protection silently. Deadlocks appear when a sync block on queue A dispatches sync to queue B while B waits on A.
Suspension Points Are Visible
Every await is a potential suspension point where the actor can run other enqueued work. This is by design — it prevents priority inversion and keeps the thread pool productive. If you need atomic read-modify-write without yielding, use a single actor method that does the whole operation.
Concrete Implementation: A Thread-Safe Counter
This actor isolates a mutable integer. External callers must use await; the compiler rejects direct access.
// Counter.swift\\nactor Counter {\\n private var value: Int = 0\\n\\n func increment() -> Int {\\n value += 1\\n return value\\n }\\n\\n func currentValue() -> Int {\\n value\\n }\\n\\n // Atomic read-modify-write without extra suspension\\n func incrementBy(_ amount: Int) -> Int {\\n value += amount\\n return value\\n }\\n}\\n\\n// Usage from async context\\nlet counter = Counter()\\nTask {\\n let v1 = await counter.increment() // 1\\n let v2 = await counter.incrementBy(5) // 6\\n print(await counter.currentValue()) // 6\\n}\\nRun this in a playground or test target with Swift 5.5+. The compiler will error if you try counter.value or call increment() without await.
Verification Steps
- Compile with Complete Concurrency Checking. In Xcode: Build Settings →
SWIFT_CONCURRENCY_CHECKS=complete. This flags any nonisolated access to actor state at compile time. - Run Thread Sanitizer. Scheme → Diagnostics → Thread Sanitizer. Write a test that spawns 100 tasks each calling
increment()1,000 times. Verify final count equals 100,000 with zero sanitizer reports. - Check deployment availability. If your minimum target is iOS 14, you cannot use actors — the concurrency runtime back-deployment is limited and unsupported for production. Use
@available(iOS 15, *)guards or stay onDispatchQueuefor that module.
Common Pitfalls to Avoid
Nonisolated Leaks
actor UserSession {\\n private var token: String = \\\"\\\"\\n // DANGER: nonisolated lets synchronous code read unprotected state\\n nonisolated var currentToken: String { token } // ❌ data race possible\\n}\\nFix: make the getter async or return a copy inside an isolated method.
Mixing Actors With DispatchQueue
actor Cache {\\n private var storage: [String: Data] = [:]\\n func load(_ key: String) async -> Data? { storage[key] }\\n}\\n\\n// Somewhere else\\nlet cache = Cache()\\nDispatchQueue.global().async { // ❌ breaks isolation\\n let data = cache.storage[\\\"key\\\"] // compiler error if storage is private,\\n // but captured references escape\\n}\\nIf you must bridge, copy data into the actor via an isolated method, or use a Task to hop onto the actor.
Assuming Actors Are Locks
Actors serialize access per instance. Two different actor instances run concurrently. If you need cross-actor invariants, compose them into a single actor or use a higher-level coordinator actor.
When to Keep DispatchQueue
- Wrapping a C callback that must run on a specific queue (e.g., Core Audio render thread).
- Simple one-shot work:
DispatchQueue.global().async { analytics.send(event) }— no shared mutable state. - Interoperating with
OperationQueue-based legacy code where refactoring to async isn't justified.
Quick Checklist Before Merging
- [ ] Minimum deployment target supports Swift concurrency runtime.
- [ ]
SWIFT_CONCURRENCY_CHECKS = completepasses with zero warnings. - [ ] Thread Sanitizer reports no races on concurrent actor stress test.
- [ ] No
nonisolatedsynchronous access to mutable actor state. - [ ] No captured
selfin closures that escape the actor withoutawait.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.