Swift Actors: A Beginner-Friendly Guide to Safe Concurrency
Swift actors are a concurrency feature designed to protect shared mutable state from data races. They let you write concurrent code while keeping access to an actor’s data automatically serialized, which makes it much easier to reason about safety.
Quick answer: An actor is like a reference type whose mutable state is protected by Swift’s concurrency system. Code outside the actor must use asynchronous access to reach isolated state, which helps prevent data races.
Difficulty: Intermediate
You'll understand this better if you know: basic Swift types, functions, async/await, and the difference between value and reference types.
1. What Is Swift Actors?
Actors are Swift types that isolate their internal mutable state. That means only one task at a time can directly access an actor’s protected data, which reduces the risk of two concurrent tasks changing the same value at the same time.
- An actor is declared with the actor keyword.
- Its properties and methods are protected by actor isolation unless marked otherwise.
- Access from outside the actor usually requires await.
- Actors are commonly used for shared mutable state such as caches, counters, and request coordinators.
Actors are similar to classes in that they are reference types, but they are different because Swift enforces safe access to their isolated state.
2. Why Swift Actors Matter
Concurrency bugs are often subtle. If two tasks read and write the same data at the same time, your program may crash, produce incorrect results, or behave inconsistently. Actors give you a language-level way to make shared mutable state safer.
They matter because they:
- reduce the chance of data races
- make concurrent code easier to maintain
- provide a clear boundary around mutable state
- fit naturally into Swift’s structured concurrency model
Use actors when multiple tasks need controlled access to the same state. Do not use them just because a type “sounds concurrent”; if the data is immutable or local to one task, an actor may be unnecessary overhead.
3. Basic Syntax or Core Idea
Declaring an actor looks similar to declaring a class, but the actor keyword changes how access to its state works.
Minimal actor definition
This example shows a simple counter protected by actor isolation. External code cannot freely read and write its stored properties without using asynchronous access.
actor Counter {
private var value = 0
func increment() {
value += 1
}
func currentValue() -> Int {
return value
}
}Inside the actor, methods can access value directly because they run in the actor’s isolated context. Outside the actor, calling those methods from concurrent code typically requires await.
Accessing actor methods from outside
When you work with an actor instance from another task, you suspend until the actor can safely handle the request.
let counter = Counter()
Task {
await counter.increment()
let value = await counter.currentValue()
print(value)
}This is the core idea behind actors: the compiler helps enforce safe access patterns instead of leaving concurrency safety entirely to you.
4. Step-by-Step Examples
Example 1: A bank account balance
A bank account is a classic actor use case because deposits and withdrawals may happen from multiple tasks. The actor keeps the balance consistent.
actor BankAccount {
private var balance: Int
init(initialBalance: Int) {
balance = initialBalance
}
func deposit(amount: Int) {
balance += amount
}
func withdraw(amount: Int) -> Bool {
guard balance >= amount else {
return false
}
balance -= amount
return true
}
func currentBalance() -> Int {
return balance
}
}This actor keeps every mutation of balance inside a protected context, so two tasks cannot update it at the same instant.
Example 2: A shared image cache
Caches are another common actor use case because many tasks may request the same data. The actor ensures the cache dictionary stays consistent.
actor ImageCache {
private var images: [String: Data] = [:]
func image(forKey key: String) -> Data? {
return images[key]
}
func store(_ data: Data, forKey key: String) {
images[key] = data
}
}The dictionary never needs manual locking because the actor already serializes access.
Example 3: Fetching from multiple tasks
Actors shine when many tasks want the same shared object. Here, several tasks update one actor safely.
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..10 {
group.addTask {
await counter.increment()
}
}
}Even though many tasks call increment, the actor ensures the increments are handled in a safe sequence.
Example 4: A read-only helper with nonisolated access
Sometimes an actor contains values that do not need protection. A nonisolated member can be accessed without suspension if it does not touch isolated mutable state.
actor APIClient {
nonisolated let baseURL = "https://example.com"
func requestPath(endpoint: String) -> String {
return baseURL + "/" + endpoint
}
}This pattern is useful for constants or helper methods that do not depend on actor-isolated mutable state.
5. Practical Use Cases
Actors are especially useful when a single shared object must stay consistent across concurrent work. Common real-world uses include:
- in-memory caches
- session managers
- token refresh coordinators
- download queues
- analytics event buffers
- rate limiters
- shared counters, flags, and statistics
Actors are a good fit when the state is mutable and shared, but the operations on it are small and frequent. They are less useful for large data processing tasks where each task can work on its own copy.
6. Common Mistakes
Mistake 1: Reading actor state as if it were a normal property
Beginners often expect to access actor-isolated properties directly from outside the actor, the same way they would with a class. Swift prevents that because the access could race with another task.
Problem: Directly reading isolated state from outside the actor causes an isolation error, because the compiler cannot guarantee safe concurrent access.
actor Counter {
var value = 0
}
let counter = Counter()
print(counter.value)Fix: Expose a method or property that can be accessed safely with await.
actor Counter {
private var value = 0
func currentValue() -> Int {
return value
}
}
let counter = Counter()
Task {
let value = await counter.currentValue()
print(value)
}The corrected version works because access happens through the actor’s asynchronous boundary.
Mistake 2: Assuming actor methods never need await
Even when a method looks simple, calling it from outside the actor is still an asynchronous operation if it touches isolated state.
Problem: Omitting await often produces a compile-time error such as “expression is 'async' but is not marked with 'await'.”
actor BankAccount {
private var balance = 100
func currentBalance() -> Int {
return balance
}
}
let account = BankAccount()
let balance = account.currentBalance()Fix: Mark the call with await so Swift can suspend until the actor safely handles the request.
let account = BankAccount()
Task {
let balance = await account.currentBalance()
print(balance)
}The fix works because the call now respects actor isolation.
Mistake 3: Sharing non-Sendable state across tasks
Actors protect their own state, but that does not automatically make every value safe to pass around freely. If you move a mutable non-Sendable object across concurrency boundaries, Swift may complain.
Problem: Passing unsafe shared mutable data between tasks can trigger sendability errors or lead to confusing races if the type is not designed for concurrency.
final class Box {
var value = 0
}
actor Store {
private var box = Box()
func boxValue() -> Int {
return box.value
}
}
let store = Store()Fix: Prefer value types, keep mutable reference types inside one isolation domain, or explicitly design the shared type to be concurrency-safe.
struct Box {
var value = 0
}
actor Store {
private var box = Box()
func boxValue() -> Int {
return box.value
}
}The corrected version is safer because the state is now a value type, which is easier for Swift concurrency to reason about.
7. Best Practices
Practice 1: Keep actor responsibilities focused
Actors work best when they manage one clear piece of shared state. A smaller actor is easier to test and less likely to become a bottleneck.
actor TokenStore {
private var token: String?
func save(_ newToken: String?) {
token = newToken
}
}A focused actor makes it obvious what state is protected and what operations are allowed.
Practice 2: Use immutable data whenever possible
If a value does not need to change, make it immutable. That reduces the amount of actor-isolated state and can simplify your design.
actor AppInfo {
nonisolated let bundleID = "com.example.app"
}Constants are easy to reason about and often do not need the same protection as changing values.
Practice 3: Prefer methods for multi-step state changes
If several changes must happen together, keep them inside one actor method so the whole operation stays atomic from the outside.
actor ShoppingCart {
private var items: [String] = []
func addItem(_ item: String) {
items.append(item)
}
func checkoutItems() -> [String] {
let currentItems = items
items.removeAll()
return currentItems
}
}This pattern helps prevent partially updated state from leaking out of the actor.
8. Limitations and Edge Cases
- Actors serialize access, so they can become a bottleneck if many tasks constantly hit the same actor.
- Actor methods may suspend at await points, so you should not assume one call will complete immediately.
- Not every property can be marked nonisolated; it must not depend on isolated mutable state.
- Actors protect access to their own state, but they do not automatically make every nested reference type safe.
- Some APIs still require careful bridging when used from older code that is not concurrency-aware.
- Reentrancy can matter: if an actor method awaits another operation, other work may run before the original method finishes.
One of the most surprising behaviors for beginners is reentrancy. If an actor method pauses at an await point, other tasks can enter the actor before the suspended method resumes. That means you should avoid assuming actor state stays unchanged across suspension points unless your design makes that safe.
9. Practical Mini Project
Here is a small but complete example of an actor-based rate limiter. It allows a fixed number of actions within a time window and is useful for APIs or button throttling logic.
import Foundation
actor RateLimiter {
private let limit: Int
private let window: TimeInterval
private var timestamps: [Date] = []
init(limit: Int, window: TimeInterval) {
self.limit = limit
self.window = window
}
func allowAction() -> Bool {
let cutoff = Date().addingTimeInterval(-window)
timestamps.removeAll { $0 < cutoff }
guard timestamps.count < limit else {
return false
}
timestamps.append(Date())
return true
}
}
let limiter = RateLimiter(limit: 3, window: 10)
Task {
for index in 1...5 {
let allowed = await limiter.allowAction()
print("Attempt \(index): \(allowed)")
}
}This project shows a real actor pattern: one isolated object controls access to shared mutable data. The actor keeps the timestamp list consistent without manual locking.
10. Key Points
- Actors protect mutable state by isolating access to it.
- External code usually needs await to call actor-isolated members.
- Actors are especially useful for shared state like caches, counters, and coordinators.
- They reduce data races, but they do not eliminate every concurrency design problem.
- nonisolated members are useful only when they do not depend on isolated mutable state.
11. Practice Exercise
- Create an actor named MessageQueue that stores pending messages.
- Add methods to enqueue a message, dequeue the oldest message, and count the queue size.
- Write a small test sequence that adds three messages, removes one, and prints the remaining count.
Expected output: The queue should return the first inserted message and then report that two messages remain.
Hint: Use an array to store messages and make all read/write access happen inside the actor.
actor MessageQueue {
private var messages: [String] = []
func enqueue(_ message: String) {
messages.append(message)
}
func dequeue() -> String? {
guard !messages.isEmpty else {
return nil
}
return messages.removeFirst()
}
func count() -> Int {
return messages.count
}
}
let queue = MessageQueue()
Task {
await queue.enqueue("First")
await queue.enqueue("Second")
await queue.enqueue("Third")
if let removed = await queue.dequeue() {
print(removed)
}
print(await queue.count())
}12. Final Summary
Swift actors are a practical way to protect shared mutable state in concurrent programs. They help the compiler enforce safe access, which removes a lot of manual locking and makes your code easier to understand.
Use actors when several tasks need coordinated access to the same data, such as caches, counters, session state, or other mutable shared resources. Keep actor responsibilities focused, prefer immutable data where possible, and remember that await is part of safe cross-actor access.
If you are continuing your Swift concurrency journey, the next natural topics are async/await, task groups, and the Sendable protocol, because they work closely with actors in real-world code.