When I enabled stricter concurrency checking on an older Swift target, the warning list was long enough to be discouraging. Several types had crossed queues for years without an obvious crash, so adding @unchecked Sendable everywhere looked like the fastest way forward. I tried it on one wrapper and immediately disliked how quiet the compiler became. I had removed the warning without answering the question behind it.
I went back and audited the type property by property. One “read-only” object exposed a mutable dictionary, and another used a serial queue for writes but performed unlocked reads. Both had seemed safe in ordinary testing. That experience changed how I treat Sendable diagnostics: they are design questions, not migration chores.
What follows is the practical process I now use. It is less about making the build green and more about being able to explain who owns every mutable value.
What the Warning Was Actually Asking Me
A Sendable value can cross a concurrency boundary without introducing a data race. A task may capture it in a @Sendable closure, an actor may accept it as an argument, or a value may be returned from one isolated context to another. The protocol has no methods, but it has semantic requirements that the compiler checks where possible.
A structure composed entirely of Sendable stored properties is often safe because copying the value gives each context independent state. An enumeration is similarly straightforward when every associated value is Sendable. Actors are implicitly Sendable because access to their isolated mutable state is serialized.
A class requires more scrutiny. Multiple contexts can hold references to the same instance, so mutable storage can be touched concurrently. The class warnings were where my migration slowed down. The cases the compiler accepted were final classes whose stored values were immutable and Sendable, plus UI types isolated to @MainActor. Whenever a class fell outside those shapes, I treated it as shared mutable state until I could prove otherwise.
The Example That Made the Risk Obvious
Suppose a detached task captures a mutable cache:
final class ImageCache {
var values: [URL: Data] = [:]
}
let cache = ImageCache()
Task.detached {
cache.values.removeAll()
}
The warning is not bureaucratic noise. The task may run concurrently with code that reads or mutates the same dictionary. Swift’s standard collections do not make an arbitrary compound operation thread-safe. Marking the class @unchecked Sendable would suppress the diagnostic while leaving the race intact.
Ask three questions when the compiler reports a crossing: Does the value need to cross at all? Can it become an immutable snapshot? If shared mutation is essential, which mechanism owns synchronization? The best fix is often to move the work to the value’s isolation domain rather than declaring the value universally shareable.
The Fix I Prefer: Pass a Snapshot
Network responses, configuration objects, identifiers, and view-model inputs are good candidates for immutable structures. Convert framework or legacy objects into purpose-built snapshots before sending them across actors:
struct UserSnapshot: Sendable {
let id: UUID
let displayName: String
let avatarURL: URL?
}
func snapshot(from user: LegacyUser) -> UserSnapshot {
UserSnapshot(
id: user.id,
displayName: user.displayName,
avatarURL: user.avatarURL
)
}
This boundary has two benefits. Concurrency safety becomes visible in the type, and unrelated mutable implementation details do not leak into another task. The receiver gets exactly the fields it needs. If the original object changes later, the snapshot still represents the state at conversion time.
Be explicit about that snapshot semantics. Copying a structure does not guarantee deep independence when it contains a reference-typed property. Every stored value still needs to be Sendable, or the apparent value boundary may hide shared mutable state.
When I Move the Whole Thing Behind an Actor
A mutable cache has a natural owner: an actor. Instead of sharing the dictionary, expose operations that run in the actor’s isolated context:
actor ImageCache {
private var values: [URL: Data] = [:]
func value(for url: URL) -> Data? {
values[url]
}
func insert(_ data: Data, for url: URL) {
values[url] = data
}
}
This design makes compound operations reviewable. If “return an existing value or insert a newly loaded one” must behave atomically, place the relevant state transitions inside actor-isolated methods and review any await boundaries for reentrancy. Sendability and isolation solve related but different problems.
@MainActor is appropriate when the state fundamentally belongs to the user interface. It should not become a universal escape hatch for unrelated background services. Overusing the main actor can hide architectural ownership issues and place unnecessary work on the UI executor.
The Few Times I Accept @unchecked Sendable
I use unchecked conformance only for a reference type that already protects every mutable value with one lock or one private serial queue. In an older image pipeline, this let me migrate the boundary without redesigning the cache in the same release, but I first traced every read, write, and callback that touched its storage.
final class LockedCounter: @unchecked Sendable {
private let lock = NSLock()
private var storage = 0
func increment() {
lock.withLock { storage += 1 }
}
func value() -> Int {
lock.withLock { storage }
}
}
The proof depends on details. Every read and write of storage must use the same lock. No method may return a mutable reference to protected data. Callbacks must not execute while the lock is held if they can re-enter the object. Compound operations need one critical section rather than several individually locked steps.
I leave a short comment beside the conformance naming the lock and the storage it protects. During review, that gives us something specific to check. If a new property bypasses the lock, the old justification no longer applies and the type has to be revisited.
Shortcuts That Looked Fine Until I Reviewed Them
One shortcut is adding @unchecked Sendable to an entire module’s wrapper types during migration. That removes useful compiler pressure and makes later audits harder. Migrate boundary by boundary, beginning with high-traffic task and actor crossings.
Another shortcut is assuming let makes every reference safe. A constant reference cannot be reassigned, but the referenced object may still contain mutable state. Similarly, a private dispatch queue is not sufficient if a property or method exposes internal mutable storage outside the queue.
A third mistake is mixing synchronization strategies without defining an order. If one method uses a lock and another uses an actor or queue to protect the same state, callers may observe races or deadlocks. Give each mutable resource one synchronization owner.
How to Test the Claim
Enable the project’s strict concurrency checking and treat new warnings as review items. Compile representative call sites, not only the type declaration, because many problems appear where values are captured or transferred. Run Thread Sanitizer on focused stress tests to find runtime races that static checking cannot prove.
Create a test that launches many task-group children against the shared API. Repeat reads, writes, and compound operations, then assert invariants rather than only checking that the process did not crash. For a counter, verify the exact final total. For a cache, verify that keys and values remain consistent. Runtime tests do not prove universal thread safety, but they can disprove an incorrect unchecked claim quickly.
Also review cancellation and deinitialization. A type may protect ordinary methods but race when callbacks outlive their owner or when cleanup happens concurrently.
Questions I Answer Before Suppressing Anything
- Confirm that the value truly needs to cross a concurrency boundary.
- Prefer an immutable Sendable snapshot when sharing is unnecessary.
- Use an actor when mutable state has one logical owner.
- For unchecked classes, document the exact synchronization invariant.
- Protect every access, including reads and error paths, with the same mechanism.
- Do not expose protected mutable storage by reference.
- Compile with strict checking and run focused Thread Sanitizer tests.
- Re-audit unchecked conformances whenever stored properties or callbacks change.
The warning count eventually reached zero, but the useful milestone came earlier: I could point to every shared value and name its owner. Most became snapshots or moved behind an actor. Only two legacy wrappers kept @unchecked Sendable, and both had a focused stress test that would fail if their locking rule was broken. That is still the standard I use when another migration warning appears.
Download the Complete Runnable Project
The complete code for this article is available in its own GitHub repository: https://github.com/2252408699/swift-sendable-safety-demo.
Download it with git clone, then follow the repository README to build and run the example locally.
