Swift 6 Concurrency and Actors: Building Thread-Safe iOS Applications

Swift 6 Concurrency and Actors

Swift 6 concurrency actors bring compile-time data race safety to iOS and macOS development. Swift 6’s strict concurrency mode turns potential data races into compilation errors, eliminating an entire category of bugs that have plagued mobile developers for years. While the transition requires effort, the payoff is code that is provably thread-safe.

This guide covers the practical migration to Swift 6 strict concurrency — understanding Sendable, using actors correctly, handling MainActor isolation, and fixing the most common compiler errors. Whether you are starting a new project or migrating an existing codebase, these patterns will get you to full concurrency safety.

Understanding Strict Concurrency

Swift 6 enforces that mutable state is never shared across concurrency domains without synchronization. The compiler tracks which values cross isolation boundaries and requires them to be Sendable:

// Swift 6: This won't compile — data race detected at compile time
class UserManager {
    var users: [User] = []  // Mutable state

    func fetchUsers() async {
        let fetched = await api.getUsers()
        users = fetched  // ERROR: Mutation of shared state
    }
}

// FIXED: Use an actor to isolate mutable state
actor UserManager {
    var users: [User] = []  // Protected by actor isolation

    func fetchUsers() async {
        let fetched = await api.getUsers()
        users = fetched  // Safe: actor serializes access
    }

    func getUser(id: String) -> User? {
        users.first { $0.id == id }  // Safe: within actor
    }
}

// Calling from outside the actor
let manager = UserManager()
let user = await manager.getUser(id: "123")  // 'await' required
Swift 6 concurrency actors iOS development
Swift 6 eliminates data races at compile time with strict concurrency checking

Sendable Types

A type is Sendable when it is safe to pass across concurrency boundaries. Value types (structs, enums) with Sendable properties are automatically Sendable. Moreover, classes must be explicitly marked and immutable:

// Value types: automatically Sendable
struct UserProfile: Sendable {
    let id: String
    let name: String
    let email: String
    // All properties are let + Sendable types = safe
}

// Enums: automatically Sendable
enum AppState: Sendable {
    case loading
    case loaded(UserProfile)  // UserProfile is Sendable
    case error(String)
}

// Classes: must be final + immutable to be Sendable
final class APIConfig: Sendable {
    let baseURL: URL
    let apiKey: String
    let timeout: TimeInterval

    init(baseURL: URL, apiKey: String, timeout: TimeInterval = 30) {
        self.baseURL = baseURL
        self.apiKey = apiKey
        self.timeout = timeout
    }
}

// Classes with mutable state: use @unchecked Sendable carefully
final class AtomicCounter: @unchecked Sendable {
    private let lock = NSLock()
    private var _count = 0

    var count: Int {
        lock.lock()
        defer { lock.unlock() }
        return _count
    }

    func increment() {
        lock.lock()
        defer { lock.unlock() }
        _count += 1
    }
}

MainActor Isolation

SwiftUI views and UIKit components run on the main thread. Swift 6 makes this explicit with @MainActor:

// ViewModel with MainActor isolation
@MainActor
@Observable
class TaskListViewModel {
    var tasks: [TaskItem] = []
    var isLoading = false
    var errorMessage: String?

    private let repository: TaskRepository

    init(repository: TaskRepository) {
        self.repository = repository
    }

    func loadTasks() async {
        isLoading = true
        errorMessage = nil

        do {
            // This crosses isolation boundary — repository must be Sendable
            // or the call must be nonisolated
            let fetched = await repository.fetchAll()
            tasks = fetched  // Safe: we're on MainActor
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    func deleteTask(_ task: TaskItem) async {
        tasks.removeAll { $0.id == task.id }  // Optimistic update
        do {
            try await repository.delete(task.id)
        } catch {
            await loadTasks()  // Rollback on failure
        }
    }
}

// Repository: actor-isolated for thread safety
actor TaskRepository {
    private let apiClient: APIClient
    private let cache: NSCache

    func fetchAll() async throws -> [TaskItem] {
        // Check cache first
        if let cached = cache.object(forKey: "all_tasks") {
            if cached.isValid { return cached.tasks }
        }

        let tasks = try await apiClient.get("/api/tasks")
        cache.setObject(CacheEntry(tasks: tasks), forKey: "all_tasks")
        return tasks
    }

    func delete(_ id: String) async throws {
        try await apiClient.delete("/api/tasks/\(id)")
        cache.removeObject(forKey: "all_tasks")
    }
}
iOS app development with Swift concurrency
MainActor ensures UI updates happen on the main thread safely

Common Migration Patterns

Fixing Closure Capture Warnings

// Before: Closure captures non-Sendable type
func processInBackground() {
    let formatter = DateFormatter()  // Not Sendable
    Task {
        let result = formatter.string(from: Date())  // WARNING
    }
}

// After: Create inside the Task
func processInBackground() {
    Task {
        let formatter = DateFormatter()  // Created in Task context
        let result = formatter.string(from: Date())  // Safe
    }
}

// Or use nonisolated(unsafe) for known-safe cases
nonisolated(unsafe) let sharedFormatter: DateFormatter = {
    let f = DateFormatter()
    f.dateStyle = .medium
    return f
}()

Migrating Delegate Patterns

// Protocol conformance with Sendable
protocol DataDelegate: AnyObject, Sendable {
    @MainActor func didReceiveData(_ data: [Item])
    @MainActor func didEncounterError(_ error: Error)
}

// Async alternative to delegates (preferred in Swift 6)
actor DataManager {
    func fetchData() -> AsyncStream {
        AsyncStream { continuation in
            Task {
                do {
                    let items = try await api.fetch()
                    continuation.yield(.loaded(items))
                } catch {
                    continuation.yield(.error(error))
                }
                continuation.finish()
            }
        }
    }
}
Thread-safe iOS mobile application
AsyncStream replaces delegate patterns for cleaner concurrency

When NOT to Enable Strict Concurrency

Full strict concurrency mode can be overwhelming on large legacy codebases. Consequently, migrate incrementally: enable per-module with Swift compiler flags, fix one module at a time, and use @preconcurrency imports for third-party libraries that haven’t adopted Sendable yet. As a result, you avoid a massive all-or-nothing migration.

Key Takeaways

Swift 6 concurrency with actors eliminates data races at compile time. Use actors for shared mutable state, @MainActor for UI-related code, and Sendable for types crossing boundaries. The migration effort pays off with zero data race crashes in production — a category of bugs completely eliminated from your app.

Related Reading

External Resources

Leave a Comment

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

Scroll to Top