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. According to Apple’s documentation, a checked Sendable class must be final, have only immutable Sendable stored properties, and have no superclass other than NSObject. Main-actor-isolated classes are a separate safe pattern because the global actor coordinates their access.
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
Unchecked conformance can be reasonable for a reference type that already protects all mutable state with a lock or a private serial queue, especially while adapting an older API. The annotation states that the developer has verified a thread-safety invariant the compiler cannot express.
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.
Add a comment next to the conformance that records the invariant and the reason checked conformance is unavailable. This is not decoration; it gives reviewers a concrete claim to verify when the type changes.
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 clean build eventually came, but that was not the part I trusted. I trusted the code only after I could point at each shared value and say who owned it, how access was serialized, and what test would fail if that rule were broken. Sendable lets the compiler verify much of that story; @unchecked Sendable should be the small, documented exception where human review supplies the missing proof.
Sources I Used While Checking the Migration
Apple defines the protocol and its checked and unchecked requirements in Sendable. The language-level model appears in The Swift Programming Language: Concurrency. Use the diagnostics produced by the Swift version configured for your application, because migration behavior and checking modes can change between toolchains.
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.
