Swift Actors: The Complete Guide to Thread-Safe Concurrency

Learn everything about Swift actors: actor isolation, @MainActor, global actors, reentrancy traps, and real-world patterns. Includes Swift 6.2 concurrency changes with production-ready code examples.

If you've ever burned an entire afternoon chasing a crash that only happens "sometimes," you know the pain. Two threads reading and writing the same memory at once — undefined behavior, corrupted values, or worse, silently wrong data that ships to production. For years, the Swift community managed this with serial dispatch queues, locks, and honestly, a lot of discipline. It worked... mostly. But it was fragile.

Actors change everything.

Introduced alongside Swift's structured concurrency model, actors give you compiler-enforced thread safety. Not the "thread safety if you remember to use the right queue" kind — actual, the-compiler-won't-let-you-screw-it-up thread safety. And with Swift 6.2's approachable concurrency improvements, they're now easier to adopt than ever.

In this guide, we'll walk through everything about Swift actors: what they are, how isolation works, how to use @MainActor and custom global actors, how to handle the subtle reentrancy trap (this one bites everyone eventually), and how Swift 6.2 changes the defaults. Every code example compiles. Every pattern is battle-tested.

What Are Swift Actors?

An actor is a reference type — like a class — that protects its mutable state from concurrent access. The Swift compiler enforces this protection at compile time, making data races on actor-isolated state flat-out impossible.

Here's the simplest possible actor:

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func current() -> Int {
        value
    }
}

From the outside, every interaction with Counter requires await:

let counter = Counter()
await counter.increment()
let current = await counter.current()
print(current) // 1

That await isn't just syntactic ceremony. It tells the compiler — and you — that this call might suspend. The actor processes one message at a time through its internal serial executor, so while your call waits, other callers queue up. No locks. No dispatch queues. No manual synchronization at all.

Actors vs Classes: The Key Differences

Actors share a lot with classes: they have properties, methods, initializers, subscripts, can conform to protocols, and support generics. But there are some critical differences worth knowing:

  • Thread safety: Actors provide built-in isolation. Classes require manual synchronization (locks, queues) — and good luck remembering every time.
  • External access: Actor properties and methods require await from outside. Class members are accessed directly.
  • Inheritance: Actors don't support inheritance. Classes do. This trips up some folks coming from OOP-heavy codebases.
  • Execution order: Actor message processing isn't strictly FIFO — task priority can reorder execution. Serial dispatch queues guarantee FIFO.

When to Use Actors

Use actors when you have mutable state that multiple tasks or threads need to access concurrently. Common scenarios include:

  • In-memory caches shared across the app
  • Network request coordinators that track in-flight requests
  • Analytics collectors that aggregate events from multiple sources
  • Database connection pools

Don't use actors for SwiftUI data models — reach for @Observable classes instead, optionally annotated with @MainActor. And don't use actors when a simple struct with value semantics would do the job just fine.

Actor Isolation Deep Dive

Actor isolation is the core mechanism that makes actors safe. Every property and method on an actor is isolated by default, meaning only code running within the actor's serial executor can access it synchronously.

Let's break that down.

Self Access Is Synchronous

Inside an actor, you access your own state without await:

actor ImageCache {
    private var cache: [URL: Data] = [:]

    func store(_ data: Data, for url: URL) {
        cache[url] = data  // No await needed — we're inside the actor
    }

    func retrieve(for url: URL) -> Data? {
        cache[url]  // Direct synchronous access
    }

    func storeAndConfirm(_ data: Data, for url: URL) -> String {
        store(data, for: url)  // Calling another method — still synchronous
        return "Stored \(data.count) bytes for \(url.lastPathComponent)"
    }
}

This feels very natural once you get used to it. Inside the actor, everything just works. It's only from the outside that you need to think about isolation.

External Access Requires await

From outside the actor, every access to isolated members suspends:

let cache = ImageCache()
await cache.store(imageData, for: imageURL)
let cached = await cache.retrieve(for: imageURL)

The nonisolated Keyword

Sometimes you have actor members that don't touch mutable state — computed properties based on constants, protocol conformances like Hashable, or utility methods. Mark these nonisolated to allow synchronous access from outside:

actor DatabaseConnection {
    let identifier: String  // Constants are implicitly nonisolated
    private var isConnected = false

    init(identifier: String) {
        self.identifier = identifier
    }

    nonisolated func description() -> String {
        // Can access 'identifier' (let) but NOT 'isConnected' (var)
        "Connection: \(identifier)"
    }

    func connect() {
        isConnected = true
    }
}

The rule is straightforward: let properties on actors are implicitly nonisolated because they can't change. Mutable state stays isolated. Simple as that.

The isolated Parameter

Here's a neat trick that doesn't get enough attention. You can pass actor isolation through function parameters using the isolated keyword. This lets a free function or method access the actor's state without await:

func resetIfNeeded(_ cache: isolated ImageCache) {
    // We have isolated access to cache — no await needed
    // This function runs on the cache's executor
}

This pattern is particularly useful when you want to share logic between multiple actor types or write actor-aware utility functions.

@MainActor: The UI Actor

@MainActor is a global actor that isolates code to the main thread. If you've ever written DispatchQueue.main.async, think of @MainActor as its modern, compiler-verified replacement.

Why It Matters

UIKit and SwiftUI both require UI updates on the main thread. Before actors, violating this rule produced subtle bugs — purple runtime warnings in Xcode if you were lucky, visual glitches or crashes if you weren't. @MainActor makes the compiler enforce this at build time, which is a massive improvement.

Applying @MainActor

You can apply @MainActor at multiple levels:

// On an entire class — all members are main-actor-isolated
@MainActor
class ProfileViewModel {
    var name = ""
    var avatarURL: URL?

    func loadProfile() async {
        let profile = await fetchProfile()
        name = profile.name        // Safe — guaranteed main thread
        avatarURL = profile.avatar  // Safe
    }
}

// On individual methods
class SettingsManager {
    @MainActor
    func updateUI(with settings: Settings) {
        // Guaranteed to run on main thread
    }

    func processInBackground() async {
        // Not main-actor-isolated — runs wherever called
    }
}

// On closures
Task { @MainActor in
    label.text = "Updated"
}

@MainActor with SwiftUI

SwiftUI views already run their body on the main actor. When you pair @MainActor with @Observable, you get a clean, safe data flow that honestly feels like how things should have always worked:

@MainActor
@Observable
class TodoListModel {
    var items: [TodoItem] = []
    var isLoading = false

    func load() async {
        isLoading = true
        items = await TodoService.fetchAll()
        isLoading = false  // UI update — safe on main actor
    }
}

struct TodoListView: View {
    @State private var model = TodoListModel()

    var body: some View {
        List(model.items) { item in
            Text(item.title)
        }
        .overlay {
            if model.isLoading { ProgressView() }
        }
        .task { await model.load() }
    }
}

Global Actors: Beyond @MainActor

@MainActor is a global actor, but it's not the only one you can use. You can define your own global actors to create isolated execution domains for specific subsystems of your app.

What Is a Global Actor?

A global actor extends actor isolation beyond a single actor instance. It lets you tag functions, properties, and entire types across your codebase to run on the same serial executor — creating a thread-safe "silo" for related operations.

Creating a Custom Global Actor

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
    private init() {}  // Prevent external instantiation
}

// Now use it like @MainActor
@DatabaseActor
class DatabaseManager {
    private var connection: Connection?

    func execute(_ query: String) -> [Row] {
        // All DatabaseManager methods run on the same serial executor
        // regardless of where they're called from
        connection?.execute(query) ?? []
    }
}

@DatabaseActor
func performMigration() {
    // This free function also runs on the DatabaseActor executor
}

Making the initializer private is important — a global actor should have exactly one instance, accessible through its shared property. I've seen codebases where people accidentally create multiple instances and wonder why things still race.

When to Use Custom Global Actors

Use custom global actors when you have a subsystem with shared mutable state that multiple types need to access safely. Good candidates:

  • Database layers where all queries must be serialized
  • Image processing pipelines that share a buffer pool
  • Logging systems that write to a shared file handle
  • Hardware interface layers (Bluetooth, camera) that require serial access

Actor Reentrancy: The Subtle Trap

Alright, this is the part that trips up even experienced developers. Pay close attention.

Actor methods are reentrant: when an actor-isolated method hits an await, other queued messages can execute before the original method resumes. The actor doesn't just sit idle while waiting — it keeps processing its mailbox.

This is by design. Without reentrancy, two actors calling each other would deadlock. But it means your assumptions about state can become invalid across an await. And that's where the bugs creep in.

The Classic Reentrancy Bug

actor BankAccount {
    var balance: Decimal = 1000

    func withdraw(_ amount: Decimal) async -> Bool {
        // Check 1: Do we have enough?
        guard balance >= amount else { return false }

        // ⚠️ SUSPENSION POINT — another task could run here!
        let authorized = await authorizationService.verify(amount)

        guard authorized else { return false }

        // Check 2: But balance may have CHANGED during the await!
        // Another withdraw could have succeeded while we were suspended
        balance -= amount  // 💥 Could go negative!
        return true
    }
}

Between the balance check and the deduction, another call to withdraw could have run to completion. Both callers see balance >= amount, both proceed, and the balance goes negative. Classic.

The Fix: Re-check After Every await

actor BankAccount {
    var balance: Decimal = 1000

    func withdraw(_ amount: Decimal) async -> Bool {
        guard balance >= amount else { return false }

        let authorized = await authorizationService.verify(amount)

        guard authorized else { return false }

        // Re-check after the suspension point
        guard balance >= amount else { return false }

        balance -= amount
        return true
    }
}

That extra guard after the await is the difference between a working app and a mysterious production bug.

Reentrancy Best Practices

  1. Keep state mutations synchronous. Perform all related state changes in a single synchronous block — no await between reading and writing related state.
  2. Re-validate after every await. Never assume state is the same after a suspension point. Always re-check preconditions.
  3. Prefer small, focused actor methods. A method that does one synchronous mutation is way safer than one that makes three network calls and mutates state between them.
  4. Use let where possible. Immutable properties are actor-independent and can be freely accessed across concurrency domains.

Real-World Actor Patterns

Theory is great, but let's look at some production-ready patterns that demonstrate actors solving real problems. These are patterns I keep reaching for in actual projects.

Pattern 1: Thread-Safe Cache with Expiration

actor ExpiringCache<Key: Hashable, Value> {
    private var storage: [Key: Entry] = [:]
    private let ttl: TimeInterval

    struct Entry {
        let value: Value
        let expiration: Date
    }

    init(ttl: TimeInterval = 300) {
        self.ttl = ttl
    }

    func get(_ key: Key) -> Value? {
        guard let entry = storage[key],
              entry.expiration > Date.now else {
            storage.removeValue(forKey: key)
            return nil
        }
        return entry.value
    }

    func set(_ key: Key, value: Value) {
        storage[key] = Entry(
            value: value,
            expiration: Date.now.addingTimeInterval(ttl)
        )
    }

    func removeAll() {
        storage.removeAll()
    }
}

This cache is safe to use from any task, any thread, any actor. No locks, no queues — just actor isolation doing its job. It's one of those things where you write it once and never worry about it again.

Pattern 2: Request Deduplicator

A common performance optimization is deduplicating concurrent requests for the same resource. Actors make this surprisingly clean:

actor RequestDeduplicator {
    private var inFlight: [URL: Task<Data, Error>] = [:]

    func fetch(_ url: URL, using session: URLSession = .shared) async throws -> Data {
        if let existing = inFlight[url] {
            return try await existing.value
        }

        let task = Task {
            let (data, _) = try await session.data(from: url)
            return data
        }

        inFlight[url] = task

        do {
            let data = try await task.value
            inFlight.removeValue(forKey: url)
            return data
        } catch {
            inFlight.removeValue(forKey: url)
            throw error
        }
    }
}

Multiple callers requesting the same URL simultaneously share a single network request. The actor guarantees that the inFlight dictionary is never touched concurrently. I've used this pattern in production apps and it's remarkably effective at reducing redundant API calls.

Pattern 3: Event Aggregator

actor AnalyticsAggregator {
    private var events: [AnalyticsEvent] = []
    private let batchSize: Int
    private let uploader: AnalyticsUploader

    init(batchSize: Int = 50, uploader: AnalyticsUploader) {
        self.batchSize = batchSize
        self.uploader = uploader
    }

    func track(_ event: AnalyticsEvent) async {
        events.append(event)

        if events.count >= batchSize {
            let batch = events
            events.removeAll()
            // Note: reentrancy is fine here because we
            // already moved events into 'batch'
            await uploader.send(batch)
        }
    }

    func flush() async {
        guard !events.isEmpty else { return }
        let batch = events
        events.removeAll()
        await uploader.send(batch)
    }
}

Notice the reentrancy-safe pattern here — we copy and clear before the await. That way, new events tracked during the upload go into a fresh array instead of being lost.

Swift 6.2: How Approachable Concurrency Changes Actors

Swift 6.2 introduced several changes that directly affect how you work with actors. If you've read our Swift 6.2 Approachable Concurrency guide, you know the philosophy: progressive disclosure. Here's how that reshapes actors specifically.

nonisolated(nonsending) — The New Default

Before Swift 6.2, nonisolated async functions behaved differently from nonisolated sync functions:

  • nonisolated sync → ran on the caller's actor
  • nonisolated async → jumped to the global cooperative pool (a background thread)

Same keyword, two behaviors. Confusing, right? Swift 6.2 fixes this with SE-0461. Now, nonisolated async functions run on the caller's actor by default, matching the synchronous behavior.

// Swift 6.2: This runs on the caller's actor (e.g., main actor)
nonisolated func fetchConfig() async -> Config {
    // Before 6.2: ran on background thread
    // After 6.2: runs on caller's actor
    return await configService.load()
}

This is one of those changes that eliminates an entire class of "why is this running on a background thread?" bugs.

@concurrent — Explicit Background Execution

So what if you actually want background execution? Swift 6.2 gives you @concurrent:

@concurrent
func processImage(_ data: Data) async -> UIImage? {
    // Explicitly runs on the cooperative pool (background)
    // Perfect for CPU-heavy work that shouldn't block the main actor
    let processor = ImageProcessor(data: data)
    return processor.applyFilters()
}

The @concurrent attribute automatically implies nonisolated, so you don't need to write both. Clean.

Default Main Actor Isolation

This one's a big deal. With SE-0466, Swift 6.2 lets you set @MainActor as the default isolation for your entire module. No more annotating every view model, coordinator, and manager with @MainActor — it's just the default. You only annotate when you want to opt out using nonisolated or @concurrent.

// In Package.swift
.target(
    name: "MyApp",
    swiftSettings: [
        .defaultIsolation(MainActor.self)
    ]
)

This flips the mental model: instead of "everything is nonisolated unless I say otherwise," it becomes "everything runs on the main actor unless I explicitly move it off." For most app targets, this dramatically reduces annotation noise while keeping your code thread-safe. I think this is going to become the standard setup for most iOS projects pretty quickly.

Performance: Actors vs Dispatch Queues vs Locks

Actors aren't free — let's be upfront about that. Every cross-isolation call goes through task scheduling, which adds overhead compared to a raw lock or synchronous dispatch queue call. Here's how they compare in practice:

  • Locks (os_unfair_lock, NSLock): Fastest for synchronous, short critical sections. No suspension, no task switching. But zero compiler enforcement — forget the lock and you're on your own.
  • Serial DispatchQueue: Strict FIFO ordering, moderate overhead. Solid for background work that needs serialization. But manual management and no compile-time safety.
  • Actors: Slightly higher per-call overhead from async scheduling. Not strictly FIFO — task priority can reorder things. But compile-time safety eliminates entire categories of bugs.

For the vast majority of applications, the performance difference is negligible — we're talking nanoseconds per call. The compile-time safety actors provide is worth far more than that marginal cost. Reserve locks for truly hot paths where you've actually measured a bottleneck, and use actors everywhere else.

Common Mistakes and How to Avoid Them

Mistake 1: Making Everything an Actor

Not every type needs to be an actor. If your type is only ever accessed from a single concurrency domain (like the main actor), a plain class with @MainActor is simpler and avoids unnecessary await calls. Don't reach for an actor when isolation at the type level isn't what you need.

Mistake 2: Holding References to Actor-Isolated State

actor Inventory {
    var items: [Item] = []
}

// ❌ This doesn't work — you can't get a reference to actor state
let inventory = Inventory()
// let ref = await inventory.items  // This returns a COPY, not a reference

Actor-isolated state is accessed via copy. If you need to mutate an array inside an actor, call an actor method — don't try to grab a reference and mutate externally. This catches a lot of developers off guard the first time.

Mistake 3: Ignoring Reentrancy in Stateful Methods

We covered this above, but it's worth repeating because it's the most common actor bug in the wild. Any method with await that also mutates state must re-validate its preconditions after every suspension point. No exceptions.

Mistake 4: Using Actors for SwiftUI View Models

SwiftUI's observation system doesn't play well with plain actors. Use @Observable classes with @MainActor instead. Actors are for concurrent shared state, not for driving UI. Trust me, you'll save yourself a lot of headaches by keeping these concerns separate.

Frequently Asked Questions

What is the difference between an actor and a class in Swift?

Both are reference types, but actors provide built-in thread safety through isolation — the compiler prevents concurrent access to mutable state. Classes have no such protection and require manual synchronization with locks or queues. Actors also don't support inheritance, and accessing their members from outside requires await.

Can Swift actors cause deadlocks?

No. Actor reentrancy prevents deadlocks by design. When an actor-isolated method hits an await, the actor continues processing other messages instead of blocking. This eliminates circular-wait deadlocks that plague lock-based approaches. However, reentrancy introduces its own complexity — state can change across suspension points, so you need to stay vigilant about that.

When should I use @MainActor versus a custom actor?

Use @MainActor for anything that touches the UI: view models, UI coordinators, and any code that updates visible state. Use custom actors for background subsystems that need serialized access — database layers, caches, file managers, or hardware interfaces. A good rule of thumb: if users see it, use @MainActor. If only your code sees it, consider a custom actor.

Are Swift actors faster than dispatch queues?

Not necessarily. Actors carry a slight overhead per call due to async task scheduling, and they don't guarantee strict FIFO ordering. Serial dispatch queues are faster for synchronous, performance-critical code. But actors provide compile-time safety that eliminates data races entirely — and for the vast majority of use cases, that trade-off clearly favors actors.

How do I share data between two actors?

Data shared between actors must conform to the Sendable protocol. Value types (structs, enums) are inherently Sendable because they're copied. Reference types must either be immutable, use internal synchronization, or be marked @unchecked Sendable when you've manually verified thread safety. The simplest approach? Just pass value types between actors whenever you can.

About the Author Editorial Team

Our team of expert writers and editors.