learn()
{ start }
build++
const tutorial

Swift 6 Strict Concurrency Explained - Best Practices and Migration Guide

A comprehensive guide to Swift 6 strict concurrency checking, including data race prevention, actor isolation, and practical migration strategies for modern iOS development.

Cuppa Team7 min read
swiftiosconcurrencyswift-6actorssendable

Swift 6 introduces strict concurrency checking as a major milestone in making Swift code safer and more reliable. This guide covers everything you need to know about the new concurrency model, best practices, and how to migrate your existing codebase.

Understanding Swift 6 Strict Concurrency

Swift 6's strict concurrency checking eliminates data races at compile time by enforcing strict isolation between concurrent contexts. This is a fundamental shift from the opt-in model in Swift 5.

Key Concepts

1. Sendable Protocol

The Sendable protocol marks types that can be safely passed across concurrency boundaries:

// Value types are implicitly Sendable
struct User: Sendable {
    let id: String
    let name: String
}

// Classes must be marked explicitly
final class UserCache: @unchecked Sendable {
    private let lock = NSLock()
    private var cache: [String: User] = [:]

    func getUser(id: String) -> User? {
        lock.lock()
        defer { lock.unlock() }
        return cache[id]
    }
}

2. Actor Isolation

Actors provide automatic synchronization for their mutable state:

actor DatabaseManager {
    private var connection: DatabaseConnection?
    private var cache: [String: Data] = [:]

    func fetchData(key: String) async -> Data? {
        // All access to mutable state is isolated
        if let cached = cache[key] {
            return cached
        }

        guard let conn = connection else { return nil }
        let data = await conn.query(key)
        cache[key] = data
        return data
    }

    nonisolated func getConnectionString() -> String {
        // Nonisolated methods can't access mutable state
        return "database://localhost"
    }
}

3. Main Actor

The @MainActor attribute ensures code runs on the main thread:

@MainActor
class ViewModel: ObservableObject {
    @Published var items: [Item] = []

    func loadItems() async {
        // This already runs on the main actor
        let data = await networkService.fetchItems()
        items = data // Safe UI update
    }
}

// Individual properties can be marked
class SomeClass {
    @MainActor var uiProperty: String = ""
    var backgroundProperty: String = "" // Can be accessed from any thread
}

Common Migration Patterns

Pattern 1: Converting Singleton to Actor

Before (Swift 5):

class APIClient {
    static let shared = APIClient()
    private var token: String?
    private let queue = DispatchQueue(label: "api")

    func setToken(_ token: String) {
        queue.async {
            self.token = token
        }
    }
}

After (Swift 6):

actor APIClient {
    static let shared = APIClient()
    private var token: String?

    func setToken(_ token: String) {
        self.token = token // Automatically synchronized
    }

    func getToken() -> String? {
        token
    }
}

// Usage
await APIClient.shared.setToken("new-token")

Pattern 2: Converting Delegates to Sendable

Before:

protocol NetworkDelegate: AnyObject {
    func didReceiveData(_ data: Data)
}

After:

protocol NetworkDelegate: AnyObject, Sendable {
    func didReceiveData(_ data: Data)
}

// Or use async callbacks
actor NetworkService {
    func fetchData() async throws -> Data {
        // Return data directly instead of delegate
    }
}

Pattern 3: Thread-Safe Property Wrappers

@propertyWrapper
struct Atomic<Value: Sendable>: @unchecked Sendable {
    private var value: Value
    private let lock = NSLock()

    init(wrappedValue: Value) {
        self.value = wrappedValue
    }

    var wrappedValue: Value {
        get {
            lock.lock()
            defer { lock.unlock() }
            return value
        }
        set {
            lock.lock()
            defer { lock.unlock() }
            value = newValue
        }
    }
}

// Usage
class Counter: @unchecked Sendable {
    @Atomic private(set) var count = 0

    func increment() {
        count += 1
    }
}

Best Practices

1. Prefer Value Types

Value types (structs, enums) are automatically Sendable:

// Good: Sendable by default
struct AppConfiguration: Sendable {
    let apiURL: URL
    let timeout: TimeInterval
    let features: Set<Feature>
}

// Avoid: Requires manual synchronization
class AppConfiguration {
    var apiURL: URL
    var timeout: TimeInterval
}

2. Use Actors for Shared Mutable State

actor ImageCache {
    private var cache: [URL: UIImage] = [:]
    private let maxSize: Int

    init(maxSize: Int = 100) {
        self.maxSize = maxSize
    }

    func image(for url: URL) -> UIImage? {
        cache[url]
    }

    func setImage(_ image: UIImage, for url: URL) {
        cache[url] = image
        if cache.count > maxSize {
            // Remove oldest entries
            let keysToRemove = cache.keys.prefix(cache.count - maxSize)
            keysToRemove.forEach { cache.removeValue(forKey: $0) }
        }
    }
}

3. Isolate UI Updates with @MainActor

@MainActor
func updateUI(with data: Data) {
    // Safe to update UI here
    label.text = String(data: data, encoding: .utf8)
}

// Or mark entire class
@MainActor
class ProfileViewController: UIViewController {
    // All methods automatically run on main thread
    func updateProfile(_ profile: UserProfile) {
        nameLabel.text = profile.name
        avatarView.image = profile.avatar
    }
}

4. Use Task Groups for Parallel Work

func fetchUserData(userIDs: [String]) async throws -> [UserProfile] {
    try await withThrowingTaskGroup(of: UserProfile.self) { group in
        for userID in userIDs {
            group.addTask {
                try await apiClient.fetchProfile(userID: userID)
            }
        }

        var profiles: [UserProfile] = []
        for try await profile in group {
            profiles.append(profile)
        }
        return profiles
    }
}

Migration Strategy

Step 1: Enable Warnings

Add to your build settings:

// Build Settings → Swift Compiler - Upcoming Features
SWIFT_UPCOMING_FEATURE_CONCURRENCY_CHECKING = YES (Warning)

Step 2: Identify Problem Areas

Common issues to look for:

  • Global mutable state
  • Shared reference types
  • Dispatch queue usage
  • Delegates with mutable state
  • Completion handlers

Step 3: Gradual Migration

// Phase 1: Add Sendable conformance
struct DataModel: Sendable { }

// Phase 2: Convert shared state to actors
actor DataStore { }

// Phase 3: Update async APIs
func fetchData() async throws -> DataModel { }

// Phase 4: Enable strict checking
// SWIFT_UPCOMING_FEATURE_CONCURRENCY_CHECKING = YES (Error)

Step 4: Testing

func testConcurrentAccess() async {
    let store = DataStore()

    await withTaskGroup(of: Void.self) { group in
        for i in 0..<100 {
            group.addTask {
                await store.addItem(Item(id: i))
            }
        }
    }

    let count = await store.itemCount()
    XCTAssertEqual(count, 100)
}

Advanced Patterns

Custom Executors

actor CustomExecutorActor {
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        customQueue.asUnownedSerialExecutor()
    }

    private let customQueue: DispatchQueue

    init(label: String) {
        self.customQueue = DispatchQueue(label: label)
    }
}

Sendable Closures

func performAsync(_ operation: @Sendable @escaping () -> Void) {
    Task {
        operation()
    }
}

// Usage
let value = 42
performAsync { @Sendable in
    print(value) // OK: value is captured
}

Common Pitfalls and Solutions

Pitfall 1: Forgetting await

// Wrong
actor Counter {
    var value = 0
}
let counter = Counter()
counter.value += 1 // Error: actor-isolated property

// Correct
await counter.increment()

Pitfall 2: Mixing Actor and Non-Actor Code

// Problematic
class ViewModel: ObservableObject {
    @Published var data: [Item] = []
    private let actor = DataActor()

    func load() {
        Task { @MainActor in
            data = await actor.fetchData() // Context switches
        }
    }
}

// Better
@MainActor
class ViewModel: ObservableObject {
    @Published var data: [Item] = []

    func load() async {
        data = await DataActor.shared.fetchData()
    }
}

Resources

Conclusion

Swift 6's strict concurrency checking represents a major step forward in building reliable, data-race-free applications. While migration requires effort, the benefits of compile-time safety and clearer concurrent code organization are significant. Start with enabling warnings, address issues incrementally, and leverage actors for shared mutable state.

The investment in migrating to Swift 6 concurrency will pay dividends in application stability and developer productivity.