Swift and SwiftUI tutorials for Swift Developers

Sendable Protocol in Swift

The Apple development ecosystem has evolved drastically in recent years. With the arrival of structured concurrency (async/await) and Actors, any modern iOS Developer needs to master how data moves safely between different execution threads. This is where one of the most critical pieces of modern Swift programming comes into play: the Sendable Protocol in Swift.

Whether you are building applications for iOS, macOS, or watchOS using Xcode and SwiftUI, understanding Sendable is no longer optional if you want to write code free of data races and prepare for the strict concurrency mode in Swift 6.

In this comprehensive tutorial, we will break down what the Sendable protocol is, why it exists, and how you can implement it in your day-to-day projects.


1. The Problem: Concurrency and Data Races

Before discussing the solution, we must understand the problem. In Swift programming, when multiple threads of execution attempt to access and modify the same piece of memory simultaneously, a data race occurs.

Data races are notorious for causing unpredictable crashes, data corruption, and bizarre behaviors that are incredibly difficult to debug in Xcode.

To solve this, Apple introduced Actors and the structured concurrency model. Actors protect their internal state by allowing only one task to access them at a time. However, what happens to the data we pass to and from an Actor or a concurrent Task? If we pass a mutable class (a reference type) from one thread to another, we remain vulnerable to data races.

This is where the magic of the Sendable Protocol in Swift comes in.


2. What is the Sendable Protocol in Swift?

In simple terms, Sendable is a “marker” protocol. If you look at its definition in the standard Swift library, you will see that it has no required methods or properties.

Its sole purpose is to communicate with the compiler. When a type (whether it is a struct, enum, class, or actor) conforms to the Sendable protocol, you are telling the Swift compiler: “It is safe to share instances of this type across different concurrent domains (threads or tasks)”.

If you try to pass a non-Sendable type to a Task or across an Actor’s boundary, the Xcode compiler will emit a warning (or an error in Swift 6), protecting you before the code reaches production.


3. Why is it crucial for an iOS Developer?

As an iOS Developer, your primary goal is to create fluid, fast applications that do not crash unexpectedly. When using SwiftUI, the user interface updates on the main thread (MainActor), but the heavy lifting (network calls, image processing, database access) happens on background threads.

Crossing this boundary between the background and the user interface requires moving data. If you master the use of Sendable, the compiler will become your best friend, mathematically guaranteeing that your applications are free of data-level concurrency vulnerabilities.


4. Types that are Implicitly Sendable

Fortunately, you don’t have to manually mark every type in your app as Sendable. Swift is smart enough to infer this conformance in many cases.

Value Types

Structs and enums are the bread and butter of Swift programming. Since value types are copied when passed from one place to another, there is no shared memory that can suffer from a data race.

Therefore, a struct is implicitly Sendable if all of its properties are also Sendable.

// Swift automatically infers that this struct is Sendable
// because String and Int (primitive types) are Sendable.
struct UserProfile {
    let username: String
    let age: Int
}

Primitive Types and Collections

Types like Int, String, Bool, and collections like Array or Dictionary (provided they contain Sendable elements) are safe by default.

Actors

Actors are reference types, but by design, they synchronize access to their mutable state. Therefore, any actor is inherently Sendable.


5. The Challenge: Making Classes Sendable

Classes (class) are reference types. If you pass an instance of a class to two different tasks, both tasks point to the same space in memory. This is a recipe for disaster in concurrency. For this reason, classes are not implicitly Sendable.

However, there are situations where you need a class to conform to the protocol. How do we achieve this in Xcode?

Option A: Immutable Classes (The safe route)

If a class cannot change its state after initialization, it is safe to share. For the compiler to accept a class as Sendable, it must be marked as final, and all its properties must be constants (let) of types that are also Sendable.

final class AppConfiguration: Sendable {
    let apiEndpoint: String
    let maxRetryCount: Int
    
    init(apiEndpoint: String, maxRetryCount: Int) {
        self.apiEndpoint = apiEndpoint
        self.maxRetryCount = maxRetryCount
    }
}

If you remove the word final or change a let to a var, Xcode will throw an error.

Option B: @unchecked Sendable (At your own risk)

Sometimes, you are working with legacy code or classes that manage their own internal synchronization (for example, using NSLock or C dispatch queues). You know the class is thread-safe, but the Swift compiler has no way to verify it.

In these cases, you can use @unchecked Sendable. This disables compiler checks for that particular type. As an iOS Developer, you must use this with extreme caution.

import Foundation

class ImageCache: @unchecked Sendable {
    private var cache: [String: Data] = [:]
    private let lock = NSLock()
    
    func save(data: Data, forKey key: String) {
        lock.lock()
        cache[key] = data
        lock.unlock()
    }
    
    func retrieve(forKey key: String) -> Data? {
        lock.lock()
        defer { lock.unlock() }
        return cache[key]
    }
}

Here, we assume responsibility. If we forget the lock, we will introduce a data race that the compiler will no longer catch.


6. Functions and Closures: The @Sendable Attribute

Data isn’t the only thing that travels between threads; code does too, in the form of closures. When you create a Task, you pass it a closure that will execute concurrently. That closure must be safe to share.

This is what the @Sendable attribute is for. A @Sendable closure restricts what you can capture inside it:

  1. It can only capture variables by value or types that conform to Sendable.
  2. It cannot capture mutable local variables (var).

Let’s look at an example of how this applies when passing functions as parameters:

// We define a function that accepts a Sendable closure
func performConcurrentWork(action: @escaping @Sendable () -> Void) {
    Task {
        // Execution happens in a concurrent domain
        action()
    }
}

class ViewController {
    var counter = 0
    
    func doWork() {
        performConcurrentWork {
            // ERROR: "Capture of 'self' with non-sendable type 'ViewController' in a `@Sendable` closure"
            // self.counter += 1 
        }
    }
}

To fix the error above, ViewController would need to be an Actor or manage its state safely.


7. Practical Tutorial: Sendable in SwiftUI and Xcode

Now that we know the theory, let’s see how this entire Swift programming ecosystem comes together in a real app using SwiftUI in Xcode. We will create a simple view that downloads weather information and see how Sendable data flows.

Step 1: Define our models (Implicitly Sendable)

We create a data model. Since it’s a struct with simple properties, it is automatically Sendable.

struct WeatherData: Codable, Sendable {
    let temperature: Double
    let condition: String
    let cityName: String
}

Step 2: Create the network service (Actor)

To avoid race conditions if multiple views try to access the service at the same time, we use an actor. Actors, as we saw, are Sendable.

actor WeatherService {
    func fetchWeather(for city: String) async throws -> WeatherData {
        // We simulate a concurrent network call
        try await Task.sleep(nanoseconds: 1_000_000_000)
        
        // We return our Sendable struct
        return WeatherData(temperature: 24.5, condition: "Sunny", cityName: city)
    }
}

Step 3: SwiftUI Integration (The MainActor)

In SwiftUI, views that update the interface must operate on the main thread. We use @MainActor on the ViewModel to guarantee this. Crossing data between WeatherService (a background actor) and WeatherViewModel (MainActor) is safe because WeatherData is Sendable.

import SwiftUI

@MainActor
class WeatherViewModel: ObservableObject {
    @Published var weather: WeatherData?
    @Published var isLoading = false
    
    private let service = WeatherService()
    
    func loadData() {
        isLoading = true
        
        // We create a Task. The closure we pass is @Sendable by default in this context.
        Task {
            do {
                // We cross the actor boundary. 
                // This is safe because WeatherData is Sendable.
                let fetchedData = try await service.fetchWeather(for: "Madrid")
                self.weather = fetchedData
                self.isLoading = false
            } catch {
                print("Error loading data")
                self.isLoading = false
            }
        }
    }
}

struct WeatherView: View {
    @StateObject private var viewModel = WeatherViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            if viewModel.isLoading {
                ProgressView("Loading weather...")
            } else if let weather = viewModel.weather {
                Text(weather.cityName)
                    .font(.largeTitle)
                Text("\(weather.temperature, specifier: "%.1f")°C")
                    .font(.system(size: 60, weight: .bold))
                Text(weather.condition)
                    .font(.title2)
            } else {
                Text("No data available")
            }
            
            Button("Update") {
                viewModel.loadData()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

This code will compile without a single concurrency warning in Xcode, even with strict mode enabled, thanks to the proper use of the Sendable Protocol in Swift.


8. Best Practices for the Modern iOS Developer

To truly master concurrency in Swift programming, here are some golden rules:

  1. Prioritize Value Types: Whenever possible, use structs and enums for your data models. By doing so, you get Sendable conformance “for free” and avoid headaches.
  2. Enable strict concurrency checking in Xcode: Go to the Build Settings of your project in Xcode, search for Strict Concurrency Checking, and set it to Complete. This will force you to adopt Sendable correctly and prepare your code for Swift 6.
  3. Avoid @unchecked Sendable unless it is the last resort: If you find yourself using it frequently, there is likely an architectural flaw in how you are managing your app’s state.
  4. Use Actors for shared mutable state: If you really need to pass mutable state between different parts of your application, encapsulate it within an actor instead of a traditional class.

9. Conclusion

The Sendable Protocol in Swift is not just another language feature; it is the foundational pillar that makes modern concurrency in the Apple ecosystem safe and robust.

As an iOS Developer, learning to structure your data so that it can be transmitted safely will save you countless hours of debugging mysterious crashes in production. Whether you are building for iOS, macOS, or watchOS using SwiftUI, embracing Sendable and letting the Xcode compiler guide you is the mark of a Swift programming professional.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Transferable Protocol in Swift

Related Posts