Why Swift Sendable Is Harder Than It Looks

Plenty of people talk about Swift's Sendable protocol as if it's just a neat compiler toggle. Slap : Sendable on your struct, silence the warnings, and get back to building features. But if you've spent any time inside a large codebase with strict concurrency turned on, you already know that Sendable compliance isn't a simple annotation. It's a deep cut into your architecture, and it dredges up decisions you've been avoiding for years.
I'm Yuki Tanaka. I spend my days untangling concurrency problems in Swift apps that grew organically over several release cycles. The move to Swift 6's data-race safety model is not a gentle migration. Sendable sits at the center of that shift, and it's far more demanding than the docs let on.
The Compiler Promise That Breaks Your Model
Sendable types promise that a value can cross concurrency domains without data races. On the surface, that reads like a type-level checkbox. In practice, it's a contract that ripples through every stored property, every closure you hang onto, and every generic parameter you name. The compiler verifies that all stored properties are themselves Sendable. A single non-Sendable field poisons the whole type, and the fix is almost never local.
Take a simple view model that holds a weak delegate reference. The delegate protocol can look completely harmless:
protocol ServiceDelegate: AnyObject {
func didUpdate()
}
class ViewModel {
weak var delegate: ServiceDelegate?
var state: String = ""
}
Try to mark ViewModel as Sendable, and the compiler stops you cold at delegate. A weak reference to an AnyObject protocol isn't Sendable unless the protocol itself is. You add : Sendable to the protocol, and now every concrete delegate type also has to be Sendable. The ripples spread fast—classes that own file handles, view controllers that reference UI state, singletons that were never designed for isolation. All of them start to complain.
Here's the hard part: Sendable isn't a local property. It's a global invariant that worms its way through your entire object graph. You can't easily fence it off in one module. Open that door, and you'll be renegotiating contracts across dozens of types.
Value Semantics Are Not Free
Structs and enums get a lot of love during Sendable adoption. If every stored property is Sendable, the compiler synthesizes conformance for you. That naturally nudges teams toward value types, which is often a healthy direction. But value semantics carry costs that are easy to overlook when you're sprinting through a migration.
A struct that wraps a large buffer turns into a copy-heavy burden the moment it crosses an isolation boundary. Every time you send it to another actor, you duplicate the buffer. If that buffer holds image data or a parsed JSON tree, you're paying a memory and CPU tax that reference semantics used to sidestep.
Worse, some types resist value semantics outright. A recursive enum that models a syntax tree might have indirect cases that introduce reference counting under the hood. Making that enum Sendable forces you to prove the referenced nodes are safe to share. You often end up wrapping them in a Sendable box, which is essentially a manual version of what the compiler would do if it could prove safety. The boilerplate piles up fast.

Closures Are the Hidden Minefield
Sendable captures in closures cause some of the most maddening bugs I see. A closure you hand to a detached task has to be @Sendable. The compiler checks that every captured value is Sendable. That feels manageable until you hit a closure that captures self in a class.
If the class isn't Sendable, the closure can't be @Sendable. You might try extracting the needed properties into local Sendable copies, but that only works for value types. If the class holds mutable state the closure needs to observe, you're stuck. The common escape hatch is to isolate the state inside an actor, but that flips the entire interaction model. Synchronous property access becomes asynchronous. Call sites that assumed immediate availability must be restructured with await.
I've watched a single non-Sendable capture inside a view's .task modifier cascade into a week-long refactor of an entire feature screen. The fix wasn't a one-line annotation. It meant splitting a manager class into an actor, rewriting delegate callbacks to use async sequences, and updating every consumer of that manager. Sendable forced a concurrency design that was long overdue—but nobody on the team pretended the process was painless.
Generics Make Sendable Viral
Generic types magnify the problem. When you write a generic struct or class, you don't know whether the type parameter is Sendable. You can add where T: Sendable, but that pushes the requirement onto every instantiation site. If a single call site uses a non-Sendable type, that site fails to compile, and the fix may sit deep in a dependency chain you don't control.
Protocols with associated types are especially unforgiving. Picture a repository protocol:
protocol Repository {
associatedtype Model
func fetch() async throws -> Model
}
If you want to store a repository instance inside a Sendable type, the associated type must be Sendable. Add a Sendable constraint to Model, and you can break conformances in modules you don't own. You might reach for type erasure with a Sendable wrapper, but that brings runtime overhead and throws away static type information. The language doesn't make this path smooth.
Sendable in generics is a design pressure that pushes you to commit to full Sendable coverage early. Half-measures leave you with compilation errors that feel random until you trace them through layers of abstraction.
The Actor Boundary Is Not a Magic Shield
Actors are the main tool for isolating mutable state. They look a lot like classes, but the actor's serial executor guards every synchronous access. It's tempting to think that dropping state into an actor makes all your Sendable worries disappear. That's only partly true.
An actor's stored properties don't need to be Sendable for internal access because all internal access is isolated. But the moment you expose those properties through a non-isolated method or a property reachable from outside the actor, Sendable rules snap back into place. You either return Sendable types or perform the access asynchronously from the correct isolation context.
And actor reentrancy makes Sendable reasoning even harder. An actor method can suspend at an await, and other tasks can interleave during that suspension. If you pass a non-Sendable reference to another actor while suspended, you risk a data race even though the actor itself enforces isolation. The compiler doesn't catch every such case statically; a lot of the responsibility shifts to your runtime invariants.
This is the point where Sendable becomes an architectural discipline instead of a syntactic one. You have to understand every suspension point inside the actor and guarantee that no shared mutable state escapes isolation during those gaps. That demands a mental model of the full call graph, not just the actor's public interface.

Testing Sendable Conformance
Static Sendable checks catch a lot of bugs, but they don't catch everything. You still need runtime tests to confirm your concurrency design holds up under real load. Swift's runtime ships with Thread Sanitizer, which can spot data races that slip past the compiler. But TSan has sharp edges: it runs only on simulators or macOS, it drags execution speed way down, and it can flag false positives with some system frameworks.
For library authors, testing Sendable correctness means writing targeted stress tests that hammer concurrent access patterns. You might spin up hundreds of tasks that read from and write to an actor, then repeat the test under TSan. If a race exists, it may not surface on every run. That non-determinism is maddening, but it mirrors the reality of concurrency bugs in production.
I've settled on running a dedicated TSan CI lane for core data-handling modules. The lane is slower, but it catches regressions that unit tests alone miss. Sendable is only as good as the verification you build around it.
Sendable and the Legacy Codebase
Most of us don't start from a greenfield project. We inherit code that predates Swift Concurrency. Those codebases are stuffed with shared mutable state, callback-heavy networking, and classes that were never meant to be copied or isolated. Applying Sendable to a codebase like that is not a mechanical migration.
The compiler's strict concurrency checking mode is opt-in for a reason. The recommended path is to enable it module by module, but even that gets tricky. A module might compile cleanly until you enable checking in its dependents, which then expose new Sendable requirements in types you thought were done.
A practical strategy is to start with leaf modules that have no internal concurrency. Make their public types Sendable, then work upward. For modules that must stay non-Sendable for a while, you can lean on @preconcurrency import to suppress errors temporarily, but that's a stopgap, not a solution. The goal is still full Sendable coverage, and for a large app the journey can take months.
When Sendable Conflicts with Other Protocols
Certain protocol combinations create real friction. Equatable and Hashable are common on model types. Sendable doesn't directly conflict with them, but if you hand-roll Equatable and compare non-Sendable properties, you introduce an implicit capture the compiler can't verify. You have to ensure the comparison logic never touches shared mutable state unsafely.
Codable is another source of tension. Synthesized Codable conformance works fine with Sendable types, but custom encoding and decoding logic can reach into non-Sendable state. If your type holds a reference to a decoder's user info dictionary, you need to guarantee that the dictionary's content is Sendable. These cross-cutting concerns demand a careful audit.
Framework interop piles on more complexity. Combine publishers, for example, are not Sendable by default. If you store an AnyCancellable in a Sendable type, you must isolate cancellation to the correct actor, or use @unchecked Sendable with manual verification. That last option is a sharp tool—if you reach for it, leave a comment that explains exactly why the unchecked conformance is safe.
Designing for Sendable from Day One
New projects get the advantage of designing with Sendable in mind from the start. That means picking value types for data models, using actors for shared mutable state, and avoiding singletons that clutch non-Sendable references. It also means writing generic code with Sendable constraints that are as minimal as possible, so you don't infect call sites needlessly.
One pattern I lean on is the "Sendable boundary" for modules that have to talk to non-Sendable legacy code. At the boundary, I convert non-Sendable types into Sendable snapshots. A complex report object that holds formatting state, for example, gets converted into a plain struct containing only the final values. That struct is Sendable, and the boundary layer is the only place that touches the unsafe internals.
This pattern adds boilerplate, but it creates a clean audit surface. Every unsafe conversion lives in a single file, and the rest of the codebase gets full Sendable safety. It's a trade-off that pays off the moment you flip on strict concurrency checking globally.
FAQ
Why does the compiler reject my struct even though all properties are Sendable?
Check for stored closures. A closure kept as a property must itself be @Sendable if the struct is Sendable. Also, confirm that any generic parameters are constrained to Sendable. Finally, if you provide a custom init that captures non-Sendable values, the compiler may not synthesize the conformance.
Can I use @unchecked Sendable to silence errors quickly?
You can, but then you own the full responsibility for data-race safety. Use it only when you've manually verified that the type's concurrency invariants are sound. Document the reasoning in a comment, and think about adding runtime assertions or TSan tests to catch regressions.
Does Sendable affect performance?
It can, indirectly. Value-type Sendable conformances encourage copying, which bumps up memory usage for large data structures. Actor isolation introduces suspension points that add latency. These costs are usually acceptable compared to the risk of data races, but you should measure them in performance-critical paths.
How do I handle third-party libraries that are not Sendable?
Wrap their types in your own Sendable abstractions at the integration boundary. Use @preconcurrency import if you need to suppress errors while you build the wrappers. File feedback with the library maintainers; the Swift ecosystem is slowly adopting Sendable, and user pressure helps prioritize the work.