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
awaitfrom 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
- Keep state mutations synchronous. Perform all related state changes in a single synchronous block — no
awaitbetween reading and writing related state. - Re-validate after every
await. Never assume state is the same after a suspension point. Always re-check preconditions. - 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.
- Use
letwhere 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:
nonisolatedsync → ran on the caller's actornonisolated 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.