All articles
iOS8 min read

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

K
Karan Pal
Author
Image Generated by AI
Image Generated by AI

📱 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:

📺 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:

The syntax is stupid simple:

actor UserSession {
    var loginCount = 0
    var currentUser: String?

    func recordLogin(username: String) {
        currentUser = username
        loginCount += 1
    }
}

Changed one word. classactor. 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 2000

All 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:

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:

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:

Outside the actor:

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 default

The 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:

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() // ✅ Correct

2\. Trying to access properties directly

let currentVal = counter.value // ❌ Error: actor-isolated property
let currentVal = await counter.value // ✅ works, but kinda awkward tbh

Accessing 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:

When NOT to Use Actors

Bad for:

Performance

Actors have overhead. Context switches, scheduling, suspensions. For most apps? Doesn’t matter. Writing a game engine? Might matter.

Tips:

Apple’s optimized this pretty well. You’re probably fine unless you’re doing something weird.

🎬 Wrap Up

Recap time.

Remember:

  1. Data races suck. Hard to find. Old fixes (locks, queues) work but ugly.
  2. Actors = bubbles for mutable state. Change class to actor, add await, done.
  3. Compile-time safety. No runtime race crashes. Compiler helps.
  4. Use await for cross-actor, nonisolated for sync, @MainActor for UI.
  5. Not perfect for everything. Structs, queues still useful sometimes.

What to Do Next

Wanna learn this properly?

📺 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! 🚀

● The newsletter

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