Swift Actors: How to Fix Data Races in Your iOS Apps
Stop fighting race conditions. Here’s how actors make concurrent Swift code safe by design

📱 Introduction
Picture this: shopping app, user hammers that “Add to Cart” button twice. Fast. What happens? Sometimes 3 items show up. Sometimes just 1. Sometimes… crash.
Fun, right?
This is what we call a data race, and if you’ve done any threading work in Swift you know exactly what I’m talking about. Usually people fix this with locks or serial queues or like a million DispatchQueue.sync calls everywhere. Makes your code look terrible.
Swift has actors now though. Not the movie kind — the concurrency kind (though honestly the movie ones might write better threaded code than me sometimes 😅).
Here’s what I’m gonna cover:
- why these race conditions keep biting us
- what actors actually are (it’s simpler than you’d think)
- how to turn your sketchy class into something safe
- when to use them… and when NOT to
📺 Prefer watching? Working on a video tutorial for this on Swift Pal. Hit subscribe if you don’t wanna miss it.
Let’s get started.
🔥 The Problem: Why Data Races Happen
Let me show you some code. Looks innocent enough:
import Foundation
class UserSession {
var loginCount = 0
var currentUser: String?
func recordLogin(username: String) {
currentUser = username
loginCount += 1
print("User \(username) logged in. Total logins: \(loginCount)")
}
func getLoginStats() -> (user: String?, count: Int) {
return (currentUser, loginCount)
}
}Looks fine, right? Now let’s use it with some concurrent tasks:
let session = UserSession()
DispatchQueue.global().async {
for _ in 1...1000 {
session.recordLogin(username: "Alice")
}
}
DispatchQueue.global().async {
for _ in 1...1000 {
session.recordLogin(username: "Bob")
}
}
// wait a sec to let both finish
Thread.sleep(forTimeInterval: 1)
let stats = session.getLoginStats()
print("Final stats: \(stats)")Expected result? 2000. Alice logs in 1000 times, Bob logs in 1000 times. Basic addition.
Actual result? 1847. Or 1923. Run again, get something else. Or it just crashes.
The problem: multiple threads accessing loginCount simultaneously. Thread A reads 42. Thread B reads 42 at basically the same moment. Both increment to 43. Both write 43. You lost a count.
That’s a data race. Worst kind of bug cause it’s random as hell. Tests pass fine, production blows up. You know the drill.
The Old-School Fixes
Before actors, you’d do something like this:
class UserSession {
private var loginCount = 0
private let queue = DispatchQueue(label: "com.app.session")
func recordLogin(username: String) {
queue.sync {
loginCount += 1
// also need to update currentUser but whatever
}
}
}Could use NSLock too if you want. Works, sure. Maintainable? Nope. Now you're managing queues everywhere, paranoid about deadlocks, the whole thing's a mess.
Surely there’s a better way?
(there is)
🎭 What Are Actors, Actually?
Think of actors like protective bubbles. That’s how I explain them anyway.
Imagine a bouncer at a club. One person in at a time. Everyone else waits. No cutting. No chaos. No race conditions.
Technically speaking: actors are reference types (like classes) that protect mutable state. Only one task can access that state at any given moment.
Key points:
- reference types (class-like, not struct-like)
- automatic property isolation
- sequential access only
- compile-time enforcement by Swift
The syntax is stupid simple:
actor UserSession {
var loginCount = 0
var currentUser: String?
func recordLogin(username: String) {
currentUser = username
loginCount += 1
}
}Changed one word. class → actor. Done.
But watch what happens when you access it from outside:
let session = UserSession()
Task {
await session.recordLogin(username: "Alice")
}See that await? Swift forces it on you. It means "hold up, might need to wait cause another task is already using this."
How Actors Differ from Classes
Reference type Class: ✅ | Actor: ✅
Inheritance Class: ✅ | Actor: ❌
Thread safety Class: Manual ❌ | Actor: Built-in ✅
Property access Class: Direct | Actor: Via await
Concurrent access Class: Unsafe ⚠️ | Actor: Safe ✅
Downside: no inheritance. But honestly? For thread safety stuff, composition usually works better than inheritance. Not losing much here.
✨ Your First Actor: Converting the Broken Example
Let’s fix that broken UserSession from earlier.
Before (Unsafe Class):
class UserSession {
var loginCount = 0
var currentUser: String?
func recordLogin(username: String) {
currentUser = username
loginCount += 1
print("User \(username) logged in. Total logins: \(loginCount)")
}
func getLoginStats() -> (user: String?, count: Int) {
return (currentUser, loginCount)
}
}After (Safe Actor):
actor UserSession {
var loginCount = 0
var currentUser: String?
func recordLogin(username: String) {
currentUser = username
loginCount += 1
print("User \(username) logged in. Total logins: \(loginCount)")
}
func getLoginStats() -> (user: String?, count: Int) {
return (currentUser, loginCount)
}
}Same exact code. Just changed the keyword. Usage looks like this:
let session = UserSession()
Task {
for _ in 1...1000 {
await session.recordLogin(username: "Alice")
}
}
Task {
for _ in 1...1000 {
await session.recordLogin(username: "Bob")
}
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
let stats = await session.getLoginStats()
print("Final stats: \(stats)") // Will ALWAYS be 2000All those await keywords? That's Swift enforcing isolation. Each recordLogin finishes before the next starts. No overlap.
Behind the scenes:
Mark something as actor, Swift does this:
- isolates mutable state
- requires
awaitfor external access - manages serial execution automatically
- throws compile errors if you try bypassing safety
Like having a thread-safety expert on your team for free.
Understanding await in This Context
“Wait, I thought await was only for async operations?"
Kinda. With actors it means something slightly different:
- might wait for another task to finish
- suspension point
- actor guarantees safe access
Doesn’t mean the operation’s slow. Not about speed — about coordination.
⚡ Actor Isolation: What You Need to Know
Actors protect state. Got it. But what needs await and what doesn't?
The Rules
Inside the actor:
- direct property access (no
await) - direct method calls
- already isolated, you’re fine
Outside the actor:
- mutable properties → need
await - methods → need
await - immutable stuff → usually still need it
Example:
actor DataManager {
var data: [String] = []
let maxSize = 100
func addItem(_ item: String) {
// inside the actor - direct access, no await
if data.count < maxSize {
data.append(item)
}
}
func getCount() -> Int {
return data.count
}
}
// Outside the actor
let manager = DataManager()
// needs await
await manager.addItem("test")
// this too
let count = await manager.getCount()
// immutable properties? still need await usually
// Swift plays it safe by defaultThe nonisolated Keyword
Got a method that doesn’t touch mutable state? Pure function, constant return value, whatever. Mark it nonisolated:
actor DataManager {
var data: [String] = []
nonisolated func getMaxCapacity() -> Int {
return 1000 // pure function
}
}
let manager = DataManager()
// no await!
let capacity = manager.getMaxCapacity()Use cases:
- doesn’t touch isolated state
- pure functions
- need synchronous access
Don’t access isolated properties inside nonisolated methods. Compiler catches that.
@MainActor for UI Updates
Common pattern: background fetch, then UI update. SwiftUI/UIKit require main thread for UI changes.
Enter @MainActor:
actor DataFetcher {
var items: [String] = []
func fetchData() async {
// simulate network call
try? await Task.sleep(nanoseconds: 1_000_000_000)
items = ["Item 1", "Item 2", "Item 3"]
}
@MainActor
func updateUI(label: UILabel) {
// runs on main thread automatically
label.text = "Fetched \(items.count) items"
}
}Quick clarification: @MainActor is a global actor for the main thread. Slap it on something, it runs there. Guaranteed.
SwiftUI uses this everywhere:
@MainActor
class ViewModel: ObservableObject {
@Published var data = [String]()
func loadData() async {
// already on main thread here
// but can still call actors with await
let fetcher = DataFetcher()
await fetcher.fetchData()
// back to main thread for UI
self.data = await fetcher.items
}
}Common Gotchas
1\. Forgetting **await** when calling from outside
actor Counter {
var value = 0
func increment() {
value += 1
}
}
let counter = Counter()
counter.increment() // ❌ Compiler error: call is async but not marked with await
await counter.increment() // ✅ Correct2\. Trying to access properties directly
let currentVal = counter.value // ❌ Error: actor-isolated property
let currentVal = await counter.value // ✅ works, but kinda awkward tbhAccessing properties directly feels awkward. Most devs wrap it in a method.
3\. Circular calls = deadlock
Don’t do this:
actor Processor {
func processA() async {
await processB() // fine
}
func processB() async {
await processA() // creates a cycle - careful!
}
}Actors prevent data races, not logical deadlocks. Don’t create circular dependencies.
🤔 Actors vs Other Approaches
So when should you use actors? When shouldn’t you?
Comparison
Actors Thread Safety: Automatic ✅ Ease: Easy ✅ Performance: Good ⚡ → Use for: shared mutable state across tasks
Serial Queue Thread Safety: Manual ✅ Ease: Medium ⚠️ Performance: Good ⚡ → Use for: legacy code, fine-grained control
Locks (NSLock) Thread Safety: Manual ⚠️ Ease: Complex ❌ Performance: Fast ⚡⚡ → Use for: low-level, performance-critical
@MainActor Thread Safety: Automatic ✅ Ease: Easy ✅ Performance: Main thread ⚡ → Use for: UI updates, main thread work
Structs (value types) Thread Safety: Automatic ✅ Ease: Very easy ✅ Performance: Very fast ⚡⚡ → Use for: immutable or local state
When to Use Actors
✅ Good for:
- shared mutable state
- network/cache/database managers
- compile-time safety
- code clarity matters
- Swift 5.5+
When NOT to Use Actors
❌ Bad for:
- data that doesn’t need sharing (use structs)
- UI state (use
@MainActoror@Published) - need inheritance
- tight perf loops
- working legacy code (refactor risk high)
Performance
Actors have overhead. Context switches, scheduling, suspensions. For most apps? Doesn’t matter. Writing a game engine? Might matter.
Tips:
- batch operations (don’t await in tight loops)
- structs are still good — don’t actor everything
- profile before worrying
Apple’s optimized this pretty well. You’re probably fine unless you’re doing something weird.
🎬 Wrap Up
Recap time.
Remember:
- Data races suck. Hard to find. Old fixes (locks, queues) work but ugly.
- Actors = bubbles for mutable state. Change
classtoactor, addawait, done. - Compile-time safety. No runtime race crashes. Compiler helps.
- Use
awaitfor cross-actor,nonisolatedfor sync,@MainActorfor UI. - Not perfect for everything. Structs, queues still useful sometimes.
What to Do Next
Wanna learn this properly?
- convert a class to actor, break things
- build a cache/session manager
- read Apple’s docs
- intentionally break stuff, see compiler errors
📺 Video version coming to Swift Pal. Subscribe.
Go write thread-safe code. 🚀
🎉 Enjoyed this article? Your support means the world to me!
💬 Drop a comment below! I love hearing about your experiences and answering questions
🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal
💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal
Happy coding! 🚀
New articles, straight to your inbox.
No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.
Keep reading
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read