Pavan Rangani

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

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

By Pavan Rangani · March 26, 2026 · Mobile Development

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

How Actor Reentrancy Catches People Off Guard

Actors serialize access to their state, but they are reentrant — and that surprises developers coming from lock-based code. When an actor method hits an await, it can suspend, and while it is suspended another call may enter the same actor and mutate state. Consequently, any assumption you made before the await may no longer hold after it resumes.

actor ImageLoader {
    private var cache: [URL: UIImage] = [:]

    func image(for url: URL) async throws -> UIImage {
        if let cached = cache[url] { return cached }

        // SUSPENSION POINT: another call can run here for the same url
        let downloaded = try await download(url)

        // Re-check: a concurrent call may have populated the cache already
        if let cached = cache[url] { return cached }
        cache[url] = downloaded
        return downloaded
    }
}

The fix is not a lock — it is defensive re-checking after every suspension point, or deduplicating in-flight work by storing the Task itself in the cache. Therefore, the mental model for actors is “mutual exclusion between synchronous slices,” not “one method runs start to finish.” Internalizing this early prevents a class of subtle cache and counter bugs.

It also helps to know which actor a given await resumes on. Calls into an actor hop to that actor’s executor, and calls back out resume wherever the caller is isolated, so a chain that bounces between a background actor and the main actor can incur several context switches. Consequently, performance-sensitive paths benefit from batching work inside a single actor method rather than crossing the boundary repeatedly. For genuinely parallel fan-out, Swift offers structured concurrency with async let and withTaskGroup, which let you launch several independent operations and await them together while the compiler still enforces that every captured value is Sendable.

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
    }
}

The @unchecked Sendable escape hatch deserves a warning. It tells the compiler “trust me, I synchronize this myself” and switches off all checking for that type. As a result, it is the right tool for a class genuinely guarded by a lock, like the counter above, but it is also where most concurrency bugs hide when teams reach for it just to silence an error. The docs recommend treating every @unchecked as a small audited exception, never as a default.

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

A practical consequence of @MainActor is that the annotation is contagious in a healthy way. Once your view model is main-actor-isolated, the compiler guarantees that tasks and isLoading are only ever read or written on the main thread, so SwiftUI never observes a torn update. Meanwhile, the heavy lifting in TaskRepository happens off the main actor, and the single await hop is the only place the two domains meet. This split — UI state on the main actor, I/O on a background actor — is the backbone of most production Swift 6 apps.

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

Taming Third-Party and Legacy Code with @preconcurrency

The hardest part of migration is rarely your own code — it is the dependency that has not adopted Sendable yet. Swift provides @preconcurrency to bridge this gap. By importing a module as @preconcurrency import LegacyKit, you tell the compiler to downgrade Sendable violations from that module to warnings, so you can keep moving while the upstream catches up.

// Suppress concurrency errors from an un-migrated dependency
@preconcurrency import AnalyticsSDK

@MainActor
final class Tracker {
    func log(_ event: String) {
        // AnalyticsSDK isn't Sendable-audited; @preconcurrency
        // lets this compile without a wall of errors today.
        AnalyticsSDK.shared.record(event)
    }
}

This is a deliberate, temporary concession rather than a permanent fix. As a result, a common pattern in production teams is to wrap the un-audited library behind your own actor or Sendable facade, so the unsafe surface area lives in exactly one file. When the dependency eventually ships proper Sendable conformances, you delete the @preconcurrency annotation and the compiler immediately tells you what is now actually unsafe.

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.

There are also cases where flipping the switch today is simply the wrong call. If your app leans heavily on a framework that has not modeled its threading in Sendable terms, or you are weeks from a release, forcing strict mode can flood you with hundreds of errors that obscure real bugs. In those situations the pragmatic move is to stay in the Swift 5 language mode with complete checking set to “minimal,” ship, and schedule the migration as deliberate technical work rather than a fire drill.

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 far fewer data race crashes in production — a category of bugs that strict checking can rule out entirely.

Related Reading

External Resources

In conclusion, Swift 6 concurrency actors are an essential topic for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.

← Back to all articles