Swift 5.5 introduced async/await and actors, which together form Swift’s modern structured-concurrency model. Actors help you write safer concurrent code by protecting mutable state (data that can change) from being accessed unsafely by multiple tasks at the same time.

This article explains what actors are, what problem they solve, and how to use them – starting with a simple story, then moving into real Swift examples (Counter, BankAccount, Logger) and finishing with @MainActor for UI updates.

The Wizard’s Tower (Actor Isolation)

A boy arrives at a wizard’s tower to ask for help.

The wizard says, “I can do it, but you must wait outside while I use my spellbook.”

So the boy makes his request, waits at the door, and only when the wizard returns does the magic take effect.

Why this matters in Swift:

  • The tower is the actor.
  • The spellbook is the actor’s mutable state (its changing data).
  • You can’t run in and change pages yourself – from outside, you must ask by calling an actor method.
  • Writing await means: “I’ll wait while the actor safely handles this request.”
  • The wizard handles one request at a time (the actor’s executor), so the spellbook never gets scrambled by two visitors at once.
What problem does this solve?

It prevents “two people changing the spellbook at the same time.”

In code, that’s a data race: bugs that appear randomly, usually under load, and are hard to reproduce.

 

The concurrency challenge before Swift 5.5

Before Swift had built-in structured concurrency, developers typically used GCD queues, operation queues, and locks. That worked, but it was easy to make mistakes: forget a lock, use the wrong queue, or update UI from a background thread.

The root hazard is shared mutable state. When multiple threads/tasks can read and write the same memory, you must coordinate access. Coordination via locks and queues is powerful – but verbose, easy to get wrong, and difficult to reason about.

Introducing actors in Swift 5.5

An actor is a reference type (like a class) with built-in protection for its mutable state. Swift enforces actor isolation rules at compile time, so unsafe cross-actor access doesn’t compile.

Under the hood, an actor processes work on a serial executor: only one piece of actor-isolated work runs at a time for a given actor. This gives you mutual exclusion for the actor’s state without writing your own locks or serial queues.

Quick definition: actor isolation

Only code running “inside the tower” may touch the spellbook directly.

Everyone else must ask the wizard (call a method) and wait (await) for the result.

 

A code example that mirrors the story

actor WizardTower {
// The tower: an actor that owns its state.
private var spellbook: [String] = []   // The spellbook (mutable state).func castLuckSpell(for name: String) {
// Only the wizard (code inside the actor) can touch the spellbook directly.
spellbook.append(“Luck spell for \(name)”)
}

func latestSpell() -> String? {
spellbook.last
}
}

let tower = WizardTower()

Task {
// The boy is outside the tower.
// ‘await’ means: “I wait at the spell-door while the wizard works.”
await tower.castLuckSpell(for: “Stephen”)

let result = await tower.latestSpell()
print(result ?? “No spell”)
}

 

Notice how the comments map to the fairy-tale rules: the spellbook lives inside the tower, so callers outside must use await to ask the actor to do work on their behalf.

Example 1: A simple counter actor

Actors are great for simple shared counters because they make increments safe without locks.

actor Counter {
private var value = 0func increment() { value += 1 }
func get() -> Int { value }
}let counter = Counter()await withTaskGroup(of: Void.self) { group in
for _ in 0..<100 {
group.addTask { await counter.increment() }
}
}print(“Final:”, await counter.get())  // 100

 

Example 2: BankAccount actor (cross-actor calls)

This example shows business logic inside an actor, plus an async transfer that calls into another actor.

actor BankAccount {
let accountNumber: Int
private var balance: Double   // In real finance code, prefer Decimal.enum BankError: Error { case insufficientFunds }init(accountNumber: Int, initialDeposit: Double) {
self.accountNumber = accountNumber
self.balance = initialDeposit
}func deposit(_ amount: Double) {
guard amount > 0 else { return }
balance += amount
}func withdraw(_ amount: Double) throws {
guard amount > 0 else { return }
guard amount <= balance else { throw BankError.insufficientFunds }
balance -= amount
}

func getBalance() -> Double { balance }

func transfer(_ amount: Double, to other: BankAccount) async throws {
try withdraw(amount)          // runs inside ‘self’
await other.deposit(amount)   // cross-actor call
}
}

 

Example 3: A logger actor

Logging is a classic “shared resource” problem. A logger actor ensures log writes and any shared buffers are updated safely.

actor Logger {
private var history: [String] = []func log(_ message: String) {
print(“LOG:”, message)
history.append(message)
}func lastMessages(_ count: Int) -> [String] {
Array(history.suffix(count))
}
}

 

@MainActor for UI updates

UI frameworks require UI updates to happen on the main thread. @MainActor is a global actor that represents the main thread, letting you express “this must run on the UI thread” in code.

A common approach is marking your whole view model @MainActor so all property changes happen safely on the main thread.

@MainActor
final class ViewModel {
private(set) var latestData: String = “”func refresh() {
Task {
let data = await someNetworkRequest()
latestData = data   // safe: always on MainActor
}
}
}

 

Two important gotchas

Reentrancy

If an actor method hits an await, the actor may run other work before the first method resumes. Don’t assume invariants remain true across an await unless you re-check them after the await.

Ordering

Work scheduled on an actor is not guaranteed to be strictly FIFO. Avoid building logic that assumes requests will be processed in exact arrival order.

Summary

Actors solve the core concurrency hazard: shared mutable state. They do this by isolating state inside an actor and forcing outside code to interact via async messages (awaited method calls). The result is safer, more readable concurrency with fewer locks and fewer “random timing” bugs.

Use actors to model clear ownership boundaries (one actor owns one piece of mutable state), and use @MainActor to keep UI updates on the main thread.

Further reading

  • Swift Evolution SE-0306: Actors (proposal)
  • Apple WWDC21: Protect mutable state with Swift actors
  • SwiftLee: Actors in Swift (practical guide)
  • Hacking with Swift: @MainActor and the main thread
  • The Actor Model (background theory)

Loading

Last modified: February 4, 2026

Author

Comments

Write a Reply or Comment

Your email address will not be published.