All articles
iOS22 min read

Understanding @Sendable: Swift 6 Changes, Interview Prep, and Real Examples

Master @Sendable protocol: value types, actors, generics, and the interview questions that stump developers

K
Karan Pal
Author
Image Generated by AI
Image Generated by AI

So you’ve probably heard about @Sendable by now, especially if you're dealing with Swift 6's new concurrency enforcement. Look, I get it — this whole concurrency thing can feel overwhelming, and @Sendable is one of those concepts that seems simple on the surface but has some real nuances underneath.

Here’s the thing: @Sendable isn't just another protocol you need to slap onto your types. It's Swift's way of ensuring thread safety in our increasingly concurrent world. And with Swift 6 making the enforcement much stricter, understanding this properly isn't optional anymore.

In this article, we’ll walk through everything from the basics to the advanced scenarios that come up in interviews. I’ll show you real code examples, explain why certain things work (or don’t), and help you understand the reasoning behind Swift’s decisions.

📺 A comprehensive video tutorial covering all these concepts is coming soon to Swift Pal: _https://youtube.com/@swift-pal_

What we’ll cover:

Actually, let me rephrase that… we’re not just covering the “what” — we’re diving into the “why” and “when” that’ll make you confident in any technical discussion.

🚨 The Swift 6 Reality Check

@Sendable is Swift's way of marking types that are safe to transfer across concurrency boundaries. Think of it as a compiler guarantee that says "this type won't cause data races when passed between different actors or tasks."

But here’s the thing — Swift 6 changed how strictly this gets enforced. What used to be warnings are now hard compiler errors. This isn’t Swift being difficult — it’s protecting us from data races that could crash our apps in production.

Let me show you what I mean with some real examples that’ll probably look familiar:

// This worked in Swift 5 (with warnings)
let mutableString = NSMutableString("Hello")
Task {
    await withTaskGroup(of: Void.self) { group in
        group.addTask { @Sendable in
            print(mutableString) // ❌ Error in Swift 6: Capture of 'mutableString' with non-sendable type 'NSMutableString' in a `@Sendable` closure
        }
    }
}

The compiler is basically saying: “Hey, you’re trying to pass a mutable reference type across concurrency boundaries, and that’s dangerous.”

Here’s another scenario that catches developers off guard:

class DataProcessor {
    private var results: [String] = []
    
    func processData() async {
        await withTaskGroup(of: String.self) { group in
            for i in 0..<10 {
                group.addTask { @Sendable in
                    // ❌ Error: Cannot capture 'self' in @Sendable closure
                    return self.processItem(i)
                }
            }
        }
    }
    
    private func processItem(_ index: Int) -> String {
        return "Item \(index)"
    }
}

Now, here’s where it gets interesting… The @Sendable requirement isn't just for explicit annotations. Swift automatically infers it for closures passed to concurrency APIs like Task, TaskGroup, and actor methods.

What actually changed in Swift 6:

The migration path? Well, you’ve got a few options:

  1. Make your types actually Sendable
  2. Use @unchecked Sendable (carefully!)
  3. Restructure your code to avoid cross-boundary sharing

🧱 @Sendable Fundamentals

Alright, let’s get to the heart of what @Sendable actually is. I know it sounds like just another protocol to learn, but it's really the cornerstone of Swift's entire concurrency safety system.

What @Sendable Actually Is

@Sendable is a marker protocol — meaning it doesn't require any methods or properties. It's Swift's way of saying "I guarantee this type is safe to transfer between different concurrency contexts." Think of it as a compile-time promise that your type won't cause data races.

Here’s the basic definition:

public protocol Sendable {
    // No requirements - it's a marker protocol
}

But don’t let the simplicity fool you. This little protocol is doing heavy lifting behind the scenes.

Why We Actually Need This

Look, before Swift Concurrency, we were playing data race roulette. You’d have code like this:

// The bad old days
var sharedCounter = 0
DispatchQueue.global().async {
    sharedCounter += 1  // Race condition waiting to happen
}
DispatchQueue.global().async {
    sharedCounter += 1  // Could corrupt data or crash
}

The problem? Two threads accessing the same memory simultaneously without synchronization. Sometimes it works, sometimes it doesn’t, and debugging these issues is a nightmare.

@Sendable solves this by making the compiler your safety net. Instead of hoping you didn't mess up thread safety, the compiler verifies it for you.

How Thread Safety Works with @Sendable

When Swift sees @Sendable, it's checking three key things:

  1. Immutability: The data won’t change after creation
  2. Value semantics: Copies don’t share references
  3. Internal synchronization: If it’s mutable, it handles its own thread safety

Let me show you what this looks like in practice

// ✅ Safe - immutable struct
struct UserProfile: Sendable {
    let id: String
    let name: String
    let email: String
}

// ✅ Safe - value type with sendable properties
struct Config: Sendable {
    let maxRetries: Int
    let timeout: TimeInterval
    let isDebugMode: Bool
}

// ❌ Not safe - mutable class without synchronization
class UserSession {
    var isLoggedIn: Bool = false
    var lastActivity: Date = Date()
}

The Compiler’s Safety Checks

When you mark something as @Sendable, Swift doesn't just trust you — it verifies. The compiler checks all stored properties are also Sendable:

// This won't compile
struct BadExample: Sendable {
    let name: String
    let session: UserSession  // ❌ Error: UserSession is not Sendable
}

When Types Automatically Conform

Swift is pretty smart about this. Some types get @Sendable conformance for free:

// All of these are automatically Sendable
let numbers: [Int] = [1, 2, 3]
let userInfo: (name: String, age: Int) = ("John", 30)
let optionalEmail: String? = "test@example.com"

Value Types vs Reference Types: The Key Difference

// Value type - safe by default if all properties are Sendable
struct Point: Sendable {
    let x: Double
    let y: Double
}

// Reference type - requires careful consideration
final class Counter: Sendable {  // Must be final!
    private let value: Int       // Must be immutable!
    
    init(value: Int) {
        self.value = value
    }
}

Now that we understand what @Sendable is and why it exists, let's dive into how different types handle conformance...

🔧 Value Types vs Reference Types

This is where things get really interesting, and honestly, where most developers start to see the practical implications of @Sendable. The behavior is completely different depending on whether you're working with value types or reference types.

Structs & Enums: The Easy Case

Structs and enums are value types, which means they copy their data when passed around. No shared references, no data races. Swift knows this, so it makes @Sendable conformance pretty straightforward.

Here’s the rule: A struct or enum automatically conforms to **Sendable** if all its stored properties are also **Sendable**.

// ✅ Automatically Sendable - all properties are Sendable
struct UserPreferences {
    let theme: String
    let fontSize: Int
    let notifications: Bool
}

// ✅ Automatically Sendable - associated values are Sendable
enum NetworkResult {
    case success(String)
    case failure(Int)
    case loading
}

// ❌ Not Sendable - contains non-Sendable property
struct UserSession {
    let id: String
    let manager: UserManager  // Assuming UserManager is a non-Sendable class
}

The beauty here is that you don’t need to explicitly declare conformance most of the time. Swift figures it out for you.

Generic Structs: Conditional Conformance

Now, this gets more nuanced with generics. A generic struct can be Sendable, but only when its generic parameters are also Sendable:

struct Container<T> {
    let value: T
}

// Container<String> is Sendable because String is Sendable
// Container<UserManager> is NOT Sendable because UserManager isn't

Swift handles this with conditional conformance automatically. You can also be explicit about it:

struct Wrapper<T: Sendable>: Sendable {
    let content: T
}

Classes: The Tricky Case

Classes are reference types, which means multiple variables can point to the same instance. This is where data races become a real threat, so Swift is much more strict about @Sendable conformance for classes.

For a class to conform to Sendable, it must be:

  1. **final** (no subclassing allowed)
  2. Have only **Sendable** stored properties
  3. Be effectively immutable or handle its own synchronization
// ✅ Valid Sendable class
final class ImmutableUser: Sendable {
    let id: String
    let name: String
    let email: String
    
    init(id: String, name: String, email: String) {
        self.id = id
        self.name = name
        self.email = email
    }
}

// ❌ Won't compile - not final (Xcode 16, on Xcode 26 it compiles)
class MutableUser: Sendable {
    let id: String
    var name: String
}

// ❌ Won't compile - has mutable properties
final class UserAccount: Sendable {
    let id: String
    var balance: Double  // Mutable property
}

The above errors compile for Xcode 26, but don’t for Xcode 16. I am still not sure why. When I have an update, I’ll put it here.

The @unchecked Sendable Escape Hatch

Sometimes you know your class is thread-safe even though the compiler can’t prove it. Maybe you’re using locks, atomic operations, or some other synchronization mechanism. That’s where @unchecked Sendable comes in:

final class ThreadSafeCounter: @unchecked Sendable {
    private var _value: Int = 0
    private let lock = NSLock()
    
    var value: Int {
        lock.lock()
        defer { lock.unlock() }
        return _value
    }
    
    func increment() {
        lock.lock()
        defer { lock.unlock() }
        _value += 1
    }
}

Warning: @unchecked Sendable turns off all compiler safety checks. You're promising the compiler that you've handled thread safety yourself. Get it wrong, and you're back to data race roulette.

Actors: Inherently Sendable

Here’s something that trips up a lot of people: actors are automatically Sendable. This makes sense when you think about it — actors isolate their state and serialize access to it, so they're safe to pass around between concurrency contexts.

actor UserStore {
    private var users: [String: User] = [:]
    
    func addUser(_ user: User) {
        users[user.id] = user
    }
    
    func getUser(id: String) -> User? {
        return users[id]
    }
}

// You can safely pass UserStore instances around
func processUsers(store: UserStore) async {
    // This is safe - the actor handles synchronization
    await store.addUser(User(id: "123", name: "John"))
}

The actor itself is Sendable, but remember — the data you pass into and out of actors must also be Sendable (unless you're working within the same actor context).

When Automatic Conformance Fails

Sometimes Swift can’t figure out Sendable conformance automatically, even when it should be possible:

struct APIResponse<T> {
    let data: T
    let timestamp: Date
    let status: Int
}

// If you want this to be explicitly Sendable when T is Sendable:
extension APIResponse: Sendable where T: Sendable {}

This conditional conformance tells Swift: “APIResponse is Sendable when T is Sendable.”

The key takeaway? Value types make @Sendable easy, classes make it challenging, and actors handle it for you. Understanding these patterns will save you a lot of debugging time when you're working with Swift's concurrency features.

🧩 Generics and @Sendable

Generics with @Sendable can be a bit of a mind-bender at first, but once you get the pattern, it's actually pretty elegant. The core principle is simple: a generic type can only be Sendable if its type parameters are also Sendable.

The Basic Pattern

Think about it logically — if you have a container that holds some type T, that container can only be safely sent across concurrency boundaries if T itself is safe to send.

struct Box<T> {
    let content: T
}

// Box<String> ✅ Sendable (String is Sendable)
// Box<NSMutableArray> ❌ Not Sendable (NSMutableArray isn't Sendable)

Swift handles this automatically through conditional conformance. You don’t usually need to do anything special — Swift figures out that Box<String> is Sendable but Box<NSMutableArray> isn't.

Explicit Generic Constraints

Sometimes you want to be more explicit about your requirements. You can constrain your generic parameters to require Sendable:

struct SafeContainer<T: Sendable>: Sendable {
    let items: [T]
    let metadata: String
    
    init(items: [T], metadata: String) {
        self.items = items
        self.metadata = metadata
    }
}

// This forces T to be Sendable at compile time
let container = SafeContainer(items: ["hello", "world"], metadata: "strings")
// let badContainer = SafeContainer(items: [NSMutableString()], metadata: "bad") // Won't compile

Collections: The Common Case

Arrays, Sets, and Dictionaries follow the same pattern. They’re Sendable when their elements are Sendable:

// All Sendable because String and Int are Sendable
let names: [String] = ["Alice", "Bob", "Charlie"]
let scores: [String: Int] = ["Alice": 95, "Bob": 87]
let uniqueIds: Set<String> = ["id1", "id2", "id3"]

// Not Sendable if elements aren't Sendable
class MutableUser {
    var name: String = ""
}
let users: [MutableUser] = [] // Array<MutableUser> is not Sendable

Optional and Result Types

These follow the same conditional conformance pattern:

// Optional<String> is Sendable because String is Sendable
let optionalName: String? = "John"

// Result is Sendable when both Success and Failure types are Sendable
let result: Result<String, NetworkError> = .success("data")

enum NetworkError: Error, Sendable {
    case timeout
    case invalidResponse
}

Generic Functions and @Sendable

Here’s where it gets interesting for function parameters. When you’re writing generic functions that work with concurrency, you often need to constrain your type parameters:

func processItems<T: Sendable>(_ items: [T]) async -> [T] {
    // Can safely process items across concurrency boundaries
    return await withTaskGroup(of: T.self, returning: [T].self) { group in
        var results: [T] = []
        
        for item in items {
            group.addTask {
                // Safe because T: Sendable
                return item
            }
        }
        
        for await result in group {
            results.append(result)
        }
        
        return results
    }
}

Multiple Generic Parameters

When you have multiple generic parameters, each one that crosses concurrency boundaries needs to be Sendable:

struct Pair<First: Sendable, Second: Sendable>: Sendable {
    let first: First
    let second: Second
}

// Both String and Int are Sendable, so this works
let nameAndAge = Pair(first: "Alice", second: 30)

Generic Protocols and Associated Types

This is where things get a bit more complex. When you have protocols with associated types, you need to think about Sendable requirements:

protocol DataProcessor {
    associatedtype Input: Sendable
    associatedtype Output: Sendable
    
    func process(_ input: Input) async -> Output
}

struct StringProcessor: DataProcessor {
    func process(_ input: String) async -> String {
        return input.uppercased()
    }
}

Conditional Conformance in Practice

Sometimes you need to explicitly declare conditional conformance for your own types:

struct Response<T> {
    let data: T
    let statusCode: Int
    let headers: [String: String]
}

// Explicitly declare when Response is Sendable
extension Response: Sendable where T: Sendable {}

// Now Response<User> is Sendable only if User is Sendable
struct User: Sendable {
    let id: String
    let name: String
}

let userResponse = Response(data: User(id: "123", name: "Alice"), statusCode: 200, headers: [:])
// userResponse is Sendable because User is Sendable

When Generics Get Tricky

The trickiest part comes when you’re dealing with generic types that contain closures or function types:

struct EventHandler<T: Sendable> {
    let handler: @Sendable (T) -> Void
    
    func handle(_ event: T) {
        handler(event)
    }
}

Notice how the closure itself needs to be @Sendable if you want to use this across concurrency boundaries.

The key insight with generics is that Sendable propagates through the type system. If any part of your generic type isn't Sendable, the whole thing becomes non-Sendable. This is actually a feature — it prevents you from accidentally creating unsafe concurrent code.

🛠 Protocol Conformance Patterns

Protocols and @Sendable can get pretty weird, especially when you start mixing existential types and protocol inheritance. This is where a lot of developers hit roadblocks, so let's break down the common patterns and pitfalls.

Existential Types: The any Protocol Challenge

When you use any Protocol, you're creating what Swift calls an existential type. This is basically a box that can hold any type conforming to that protocol. The catch? For it to be Sendable, the protocol itself needs to inherit from Sendable.

// This won't work for Sendable contexts
protocol DataSource {
    func fetchData() -> String
}

struct Container {
    let source: any DataSource  // ❌ Not Sendable
}

// This works
protocol SendableDataSource: Sendable {
    func fetchData() -> String
}

struct SafeContainer: Sendable {
    let source: any SendableDataSource  // ✅ Sendable
}

Here’s what’s happening: when you box a type with any Protocol, Swift needs to guarantee that whatever's inside that box is safe to send across concurrency boundaries. The only way it can do that is if the protocol itself requires Sendable conformance.

Protocol Inheritance Patterns

The cleanest approach is often to make your protocols inherit from Sendable from the start:

protocol NetworkService: Sendable {
    func request(_ url: String) async throws -> Data
}

protocol DatabaseStore: Sendable {
    func save<T: Codable & Sendable>(_ object: T) async throws
    func fetch<T: Codable & Sendable>(_ type: T.Type, id: String) async throws -> T?
}

This way, any type conforming to these protocols is automatically required to be Sendable, and you can use any NetworkService or any DatabaseStore in concurrent contexts without issues.

When You Can’t Change the Protocol

Sometimes you’re working with protocols you don’t control, or adding Sendable would be a breaking change. That's where things get creative:

// Existing protocol you can't modify
protocol LegacyService {
    func process(_ data: String) -> String
}

// Create a Sendable wrapper protocol
protocol SendableLegacyService: LegacyService, Sendable {}

// Bridge existing conformances
extension MyLegacyServiceImpl: SendableLegacyService {}

// Now you can use it safely
struct ServiceContainer: Sendable {
    let service: any SendableLegacyService
}

The @unchecked Sendable Escape Hatch (Revisited)

We touched on this earlier, but it’s worth diving deeper because it’s particularly relevant for protocol conformance. Sometimes you know a type is thread-safe, but the compiler can’t prove it:

final class ThreadSafeCache<Key: Hashable, Value>: @unchecked Sendable {
    private var storage: [Key: Value] = [:]
    private let queue = DispatchQueue(label: "cache", attributes: .concurrent)
    
    func get(_ key: Key) -> Value? {
        return queue.sync { storage[key] }
    }
    
    func set(_ key: Key, value: Value) {
        queue.async(flags: .barrier) { [weak self] in
            self?.storage[key] = value
        }
    }
}

Use **@unchecked Sendable** when:

Don’t use it when:

Protocol Associated Types and Sendable

This gets interesting when your protocols have associated types:

protocol Repository: Sendable {
    associatedtype Model: Sendable
    
    func save(_ model: Model) async throws
    func find(id: String) async throws -> Model?
}

struct UserRepository: Repository {
    typealias Model = User  // User must be Sendable
    
    func save(_ user: User) async throws {
        // Implementation
    }
    
    func find(id: String) async throws -> User? {
        // Implementation
    }
}

The constraint associatedtype Model: Sendable ensures that any concrete type implementing this protocol can only use Sendable types for their model.

Generic Constraints in Protocol Methods

You can also add Sendable constraints to specific methods without requiring the entire associated type to be Sendable:

protocol DataProcessor: Sendable {
    func process<T: Sendable>(_ data: T) async -> T
    func batchProcess<T: Sendable>(_ items: [T]) async -> [T]
}

Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting about protocol methods that return non-Sendable types

protocol UserManager: Sendable {
    func getCurrentUser() -> NSMutableString  // ❌ NSMutableString isn't Sendable
}

Pitfall 2: Protocol compositions without thinking about Sendable

protocol A: Sendable {}
protocol B {}  // Not Sendable

// This won't be Sendable even though A is
typealias Combined = A & B

Pitfall 3: Assuming existentials are always safe

// This looks safe but isn't necessarily
func process(_ service: any NetworkService) async {
    // If NetworkService doesn't inherit Sendable, this could be problematic
}

Best Practices for Protocol Design

  1. Design with concurrency in mind: If your protocol might be used in concurrent contexts, inherit from Sendable from the start
  2. Use associated type constraints: Require Sendable for associated types that will cross concurrency boundaries
  3. Be explicit about threading requirements: Document whether implementations need to be thread-safe
  4. Prefer composition over **@unchecked**: Try to design your way out of needing @unchecked Sendable

The key insight here is that Sendable propagates through your protocol hierarchy. If you want to use protocol types in concurrent code, you need to think about Sendable at the protocol design level, not just at the implementation level.

🎭 Actor Isolation Deep Dive

Actors are where @Sendable really starts to make sense in practice. But there's way more to the story than just @MainActor — custom actors, isolation boundaries, and some pretty nuanced rules about what can cross those boundaries.

Beyond @MainActor: Custom Actors

Most developers start with @MainActor, but custom actors are where you get real control over concurrency. Here's the thing — actors are automatically Sendable, but the data flowing in and out of them follows strict rules:

actor UserStore {
    private var users: [String: User] = [:]
    private var lastUpdated: Date = Date()
    
    func addUser(_ user: User) {
        // user must be Sendable to cross the actor boundary
        users[user.id] = user
        lastUpdated = Date()
    }
    
    func getUsers() -> [User] {
        // Return value must be Sendable too
        return Array(users.values)
    }
}

The actor itself can have mutable state — that’s the whole point. But anything crossing into or out of the actor must be Sendable.

Actor Isolation Rules

Here’s where it gets interesting. When you’re inside an actor, you can work with non-Sendable types freely:

actor DocumentProcessor {
    private var workingDocument: NSMutableString = NSMutableString()
    
    func processText(_ input: String) {
        // This is fine - we're isolated inside the actor
        workingDocument.append(input)
        workingDocument.append(" processed")
    }
    
    func getResult() -> String {
        // We return a copy (String), not the mutable original
        return String(workingDocument)
    }
}

The NSMutableString never leaves the actor, so there's no risk of data races.

The nonisolated Keyword

Sometimes you have methods that don’t need actor isolation. Maybe they’re pure functions or they only access immutable data:

actor MathProcessor {
    private var results: [Double] = []
    
    // This doesn't need isolation - it's a pure function
    nonisolated func calculateSquare(_ number: Double) -> Double {
        return number * number
    }
    
    // This needs isolation - it modifies actor state
    func storeResult(_ result: Double) {
        results.append(result)
    }
}

nonisolated methods can be called synchronously from outside the actor, but they can't access isolated state.

Cross-Actor Communication

When actors talk to each other, everything crossing those boundaries must be Sendable:

actor DataStore {
    private var cache: [String: String] = [:]
    
    func getValue(_ key: String) -> String? {
        return cache[key]
    }
}

actor NetworkManager {
    private let dataStore: DataStore
    
    init(dataStore: DataStore) {
        self.dataStore = dataStore  // DataStore (actor) is Sendable
    }
    
    func fetchAndCache(_ url: String) async throws {
        let data = try await downloadData(url)  // String is Sendable
        await dataStore.setValue(data, for: url)
    }
}

Actor Reentrancy and Sendable

This is a subtle but important point. Actors can be reentrant — they can suspend and resume, potentially processing other messages in between. This makes Sendable requirements even more important:

actor OrderProcessor {
    private var orders: [Order] = []
    
    func processOrder(_ order: Order) async {
        orders.append(order)
        
        // Suspension point - other messages could be processed here
        await validateWithServer(order)
        
        // The orders array might have changed while we were suspended
        // This is why we need Sendable guarantees
        markAsProcessed(order)
    }
}

Global Actors Beyond @MainActor

You can create your own global actors for specific domains:

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

@DatabaseActor
class DatabaseManager {
    private var connections: [Connection] = []
    
    func executeQuery(_ sql: String) async -> [Row] {
        // All methods run on DatabaseActor
        // Parameters and return values must be Sendable
    }
}

Actor Isolation and Closures

When you create closures inside actors, they can capture actor-isolated state, but there are rules:

actor TaskManager {
    private var tasks: [Task] = []
    
    func scheduleTasks() {
        for task in tasks {
            // This closure is actor-isolated
            Task {
                await processTask(task)  // Can access actor methods
            }
        }
    }
    
    func scheduleDetachedTask() {
        Task.detached {
            // This is NOT actor-isolated
            // await self.processTask(task)  // Would need 'await'
        }
    }
}

Sendable Functions and Actor Methods

Function types that cross actor boundaries need to be @Sendable:

actor EventProcessor {
    typealias EventHandler = @Sendable (Event) -> Void
    
    private var handlers: [EventHandler] = []
    
    func addHandler(_ handler: @escaping @Sendable (Event) -> Void) {
        handlers.append(handler)
    }
    
    func processEvent(_ event: Event) {
        for handler in handlers {
            handler(event)  // Safe because handler is @Sendable
        }
    }
}

Common Actor Patterns and Pitfalls

Pattern: Actor as a Coordinator

actor TaskCoordinator {
    private var workers: [WorkerActor] = []
    
    func distributeWork(_ items: [WorkItem]) async {
        for (index, item) in items.enumerated() {
            let worker = workers[index % workers.count]
            await worker.process(item)
        }
    }
}

Pitfall: Trying to pass non-Sendable data between actors

actor ActorA {
    func sendToB(_ actorB: ActorB, data: NSMutableArray) async {
        // ❌ Won't compile - NSMutableArray isn't Sendable
        await actorB.receive(data)
    }
}

Pitfall: Forgetting about suspension points

actor Counter {
    private var count = 0
    
    func incrementSlowly() async {
        let currentCount = count
        await Task.sleep(nanoseconds: 1000)  // Suspension point!
        count = currentCount + 1  // ❌ count might have changed!
    }
}

Testing Actors and Sendable

When testing actors, remember that test methods often need to be async and handle Sendable requirements:

class UserStoreTests: XCTestCase {
    func testAddUser() async {
        let store = UserStore()
        let user = User(id: "123", name: "Test")  // User must be Sendable
        
        await store.addUser(user)
        let users = await store.getUsers()
        
        XCTAssertEqual(users.count, 1)
    }
}

The key insight here is that actors provide isolation, but @Sendable provides the safety guarantees for what crosses those isolation boundaries. Understanding both concepts together is crucial for building robust concurrent systems.

⚡ Performance Reality Check

Let’s talk about the elephant in the room — does all this @Sendable stuff actually slow down your app? The short answer is: mostly no, but there are some nuances worth understanding.

Compile-Time vs Runtime: The Truth

Here’s the thing that surprises most developers: @Sendable conformance itself has zero runtime overhead. It's purely a compile-time safety check. Think of it like type checking — the compiler verifies everything is correct, then strips away the safety annotations.

struct User: Sendable {
    let id: String
    let name: String
}

// At runtime, this is just a regular struct
// No extra memory, no extra CPU cycles for being Sendable

The performance characteristics of a Sendable struct are identical to a non-Sendable struct. Same goes for classes, enums, and everything else.

Where Performance Actually Matters

The performance implications come from the patterns you use to achieve Sendable conformance, not from the conformance itself.

Value Types vs Reference Types:

// Fast - value type, stack allocated
struct FastUser: Sendable {
    let id: String
    let name: String
}

// Slower - reference type, heap allocated
final class SlowUser: Sendable {
    let id: String
    let name: String
    
    init(id: String, name: String) {
        self.id = id
        self.name = name
    }
}

he performance difference here isn’t because of @Sendable — it's because classes require heap allocation and reference counting, while structs can often be stack-allocated.

Existential Types: The Real Performance Killer

This is where things get interesting. When you use any Protocol, you're introducing real performance overhead:

protocol DataProcessor: Sendable {
    func process(_ data: String) -> String
}

struct FastProcessor: DataProcessor {
    func process(_ data: String) -> String {
        return data.uppercased()
    }
}

// Fast - direct method call
let processor = FastProcessor()
let result = processor.process("hello")

// Slower - existential type with dynamic dispatch
let anyProcessor: any DataProcessor = FastProcessor()
let result2 = anyProcessor.process("hello")

The any DataProcessor version involves:

How much slower? It depends, but we’re typically talking about 2–3x overhead for simple operations. For most real-world scenarios, this is negligible, but it can matter in tight loops.

Copying Costs

Since @Sendable often pushes you toward value types, you might worry about copying costs:

struct LargeData: Sendable {
    let values: [Double] // Could be thousands of elements
    let metadata: [String: String]
}

func processData(_ data: LargeData) async {
    // Is this copying expensive?
}

Here’s the good news: Swift uses copy-on-write (COW) for collections like Array and Dictionary. So copying a LargeData struct is actually just copying a few pointers until you modify something.

Actor Overhead

Actors do introduce some runtime overhead, but it’s generally minimal:

actor DataStore {
    private var data: [String: String] = [:]
    
    func setValue(_ value: String, for key: String) {
        data[key] = value  // This requires actor synchronization
    }
}

The overhead includes:

But this is the cost of thread safety, not the cost of @Sendable. You'd pay similar costs with manual locking or other synchronization approaches.

When to Actually Worry About Performance

Don’t worry about performance when:

Do consider performance when:

Measuring Real Impact

If you’re concerned about performance, measure it. Here’s a simple pattern:

func measureTime<T>(operation: () throws -> T) rethrows -> (result: T, time: TimeInterval) {
    let startTime = CFAbsoluteTimeGetCurrent()
    let result = try operation()
    let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
    return (result, timeElapsed)
}

// Compare approaches
let (result1, time1) = measureTime {
    // Direct struct usage
    processUsers(users)
}

let (result2, time2) = measureTime {
    // Existential type usage
    processUsersGeneric(users as [any UserProtocol])
}

Optimization Strategies

Strategy 1: Prefer concrete types over existentials

// Instead of this
func process(_ items: [any Processable]) -> [String] {
    return items.map { $0.process() }
}

// Consider this
func process<T: Processable>(_ items: [T]) -> [String] {
    return items.map { $0.process() }
}

Strategy 2: Use actors judiciously

// Fine for coordination
actor TaskCoordinator {
    func assignTask(_ task: Task) async { }
}

// Overkill for simple state
struct SimpleCounter: Sendable {  // Just use atomic operations or immutable updates
    let value: Int
}

Strategy 3: Batch actor operations

// Instead of multiple calls
for item in items {
    await actor.process(item)
}

// Batch them
await actor.processItems(items)

The Bottom Line

@Sendable itself is free. The patterns it encourages (value types, immutability, proper isolation) are generally good for performance anyway. The few cases where you might see overhead are usually worth it for the safety guarantees you get.

In most apps, you’re far more likely to have performance problems from inefficient algorithms, unnecessary network requests, or poor UI updates than from @Sendable conformance. Focus on those first, then optimize concurrency patterns if measurements show they're actually a bottleneck.

🎤 Interview Questions & Scenarios

Alright, let’s get to the meat of what you’re probably here for — the interview questions that actually come up when discussing @Sendable. I've seen these patterns repeatedly, and honestly, they're designed to test both your theoretical understanding and practical experience.

The Classic “What’s Wrong With This Code?” Questions

Question 1: Spot the Issue

class UserManager {
    private var currentUser: User?
    
    func updateUserAsync() {
        Task {
            self.currentUser = User(id: "123", name: "John")
        }
    }
}

What they’re testing: Do you understand that capturing self in a Task requires the class to be Sendable?

Answer: The issue is that UserManager isn't Sendable, but we're capturing self in a Task closure, which requires @Sendable. The solutions are:

  1. Make UserManager conform to Sendable (requires making it final and ensuring thread safety)
  2. Use @MainActor on the class
  3. Restructure to avoid capturing self

Question 2: The Generic Trap

struct DataContainer<T>: Sendable {
    let items: [T]
}

let container = DataContainer(items: [NSMutableString("test")])

What they’re testing: Do you understand conditional conformance with generics?

Answer: This won’t compile because DataContainer<T> can only be Sendable when T is Sendable. NSMutableString isn't Sendable, so DataContainer<NSMutableString> can't be Sendable either. The struct declaration should be struct DataContainer<T: Sendable>: Sendable to make this explicit.

Protocol Design Questions

Question 3: “How would you design this protocol?”

“You need to create a protocol for data repositories that will be used in concurrent environments. What would you consider?”

Strong Answer:

protocol Repository: Sendable {
    associatedtype Model: Sendable & Codable
    
    func save(_ model: Model) async throws
    func find(id: String) async throws -> Model?
    func findAll() async throws -> [Model]
}

Why this is good:

Actor Design Scenarios

Question 4: “What’s wrong with this actor design?”

actor CacheManager {
    private var cache: [String: Any] = [:]
    
    func setValue(_ value: Any, for key: String) {
        cache[key] = value
    }
    
    func getValue(for key: String) -> Any? {
        return cache[key]
    }
}

What they’re testing: Do you understand that Any breaks Sendable safety?

Answer: The problem is using Any type, which isn't Sendable. This means you could store non-Sendable objects and then retrieve them outside the actor, breaking thread safety. Better design:

actor CacheManager {
    private var cache: [String: any Sendable] = [:]
    
    func setValue<T: Sendable>(_ value: T, for key: String) {
        cache[key] = value
    }
    
    func getValue<T: Sendable>(for key: String, as type: T.Type) -> T? {
        return cache[key] as? T
    }
}

Performance and Trade-off Questions

Question 5: “When would you use @unchecked Sendable?”

Strong Answer: Only when you can guarantee thread safety but the compiler can’t verify it. Examples:

Advanced Debugging Scenarios

Question 6: “This code compiles but feels wrong. What would you change?”

@MainActor
class ViewModel: ObservableObject {
    @Published var data: [String] = []
    
    func loadData() {
        Task.detached {
            let newData = await fetchFromAPI()
            await MainActor.run {
                self.data = newData
            }
        }
    }
}

What they’re testing: Understanding of actor isolation and when Task.detached is appropriate.

Better approach:

@MainActor
class ViewModel: ObservableObject {
    @Published var data: [String] = []
    
    func loadData() {
        Task {  // Inherits @MainActor context
            let newData = await fetchFromAPI()
            self.data = newData  // Already on MainActor
        }
    }
}

Red Flag Answers to Avoid

What Interviewers Are Really Looking For

  1. Understanding of the problem space — Do you get why @Sendable exists?
  2. Practical experience — Have you actually debugged these issues?
  3. Design thinking — Can you architect systems with concurrency in mind?
  4. Trade-off awareness — Do you understand when to prioritize safety vs performance vs simplicity?

The key is showing that you understand both the theoretical foundations and the practical implications. Don’t just memorize the rules — understand the reasoning behind them.

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading