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
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")
}
}
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()
}
}
}
}
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.