All articles
iOS15 min read

Understanding Actors in Swift: Write Safer, Concurrent Code with Ease

Confused by Swift’s new actor keyword? Learn how actors help you write safe, crash-free concurrent code — without diving into GCD or…

K
Karan Pal
Author
Image generated by AI
Image generated by AI

🧠 Introduction — Why This Actor Deserves Your Attention

Concurrency in Swift used to be… well, chaotic. 😅

Between juggling DispatchQueue, DispatchGroup, and good ol’ NSLock, writing thread-safe code often felt like playing Jenga on a rollercoaster — thrilling, but one wrong move and it crashes.

Then came Swift’s structured concurrency model. And with it, a new keyword strutted onto the stage like it owned the place: actor.

But what is an actor in Swift?

Is it just another class in costume? A concurrency magic wand? Or the superhero we needed all along? 🦸‍♂️

In this article, we’ll break down:

And don’t worry — no Shakespeare, no runtime drama, and no death scenes. Just clear examples, gotchas to avoid, and real-world use cases.

🧵 The Problem with Shared Mutable State

Before actors entered the scene, Swift devs had to manually protect shared data — and let’s be honest, it wasn’t pretty.

Imagine you’ve got a BankAccount class with a balance. Two threads try to deposit money at the same time. Seems harmless? Boom 💥 — race condition. Suddenly your balance is missing ₹500, and nobody knows why.

Here’s the villain in action:

class BankAccount {
    var balance: Int = 0

    func deposit(_ amount: Int) {
        balance += amount
    }
}

Now imagine this is being accessed from multiple threads simultaneously:

let account = BankAccount()

DispatchQueue.global().async {
    account.deposit(1000)
}

DispatchQueue.global().async {
    account.deposit(2000)
}

This is how you get inconsistent results, corrupted state, and a million dollar “Oops.” 😬

Why Does This Happen?

Because Swift classes are reference types, and mutable state isn’t thread-safe by default. When two threads try to read and write the same data without coordination, it’s a party Swift’s memory model doesn’t enjoy.

The Old Fixes (That We Hated)

This is where Swift’s actor comes in, tapping you on the shoulder like:

“Hey. I got this. Just write your code, I’ll make sure no two threads are poking the same variable at once.”

🎯 Boom — safe, isolated, concurrent access. Let’s meet the hero next.

🎭 Meet actor: Swift’s Concurrency Bodyguard

Enter actor — a new reference type in Swift that behaves like a class, but with a twist: it’s designed for concurrency safety right out of the box.

If you’re coming from the world of class, think of an actor as:

Here’s what an actor looks like:

actor BankAccount {
    private var balance: Int = 0

    func deposit(_ amount: Int) {
        balance += amount
    }

    func getBalance() -> Int {
        return balance
    }
}

And here’s how you interact with it:

let account = BankAccount()

Task {
    await account.deposit(1000)
    let currentBalance = await account.getBalance()
    print("Balance: \(currentBalance)")
}

🔐 Why Is This Safe?

Because Swift automatically serializes access to the actor’s internal state. That means:

🧠 Actor vs Class: The Key Differences

Here’s how actor differs from a regular class in Swift:

Thread Safety

Concurrent Access

Method Access

Inheritance

Use in UI Code

Think of actor as your code’s personal bodyguard: no matter how many tasks try to barge in, only one gets through at a time. 🕴️

Up next, let’s learn how to use actors in real code — no theory, just hands-on.

🛠️ How to Use Actors in Swift (With Examples)

Using actors in Swift is surprisingly straightforward — like using a class, but with superpowers 🦸‍♀️ (and a tiny bit of await magic).

Let’s walk through it with a simple and relatable example — a Task Tracker that logs completed tasks:

👇 Step 1: Define Your Actor

actor TaskTracker {
    private var completedTasks: [String] = []

    func add(task: String) {
        completedTasks.append(task)
    }

    func allTasks() -> [String] {
        return completedTasks
    }
}

✅ No locks.

✅ No DispatchQueue.

✅ Just clean, isolated state.

👇 Step 2: Interact with the Actor

Since actor protects its data, you need to use await when calling its methods from outside the actor:

let tracker = TaskTracker()

Task {
    await tracker.add(task: "Write Medium article 📝")
    await tracker.add(task: "Refactor legacy code 😩")

    let tasks = await tracker.allTasks()
    print("Completed: \(tasks)")
}

⚠️ Remember: You must use await when accessing actor methods from outside.

If you try to skip it, Swift will give you the stink eye 👀 (aka a compiler error).

💡 Bonus: Actor Initialization

You can also write custom init methods like this:

actor ProfileManager {
    let username: String
    private var loginCount = 0

    init(username: String) {
        self.username = username
    }

    func incrementLogin() {
        loginCount += 1
    }

    func currentLoginCount() -> Int {
        return loginCount
    }
}

Then use it the same way:

let manager = ProfileManager(username: "swift_karan")

Task {
    await manager.incrementLogin()
    print("Logins: \(await manager.currentLoginCount())")
}

🧪 TL;DR — Quick Checklist

Up next: let’s talk about pitfalls, because not everything is sunshine and thread safety 🌥️

⚠️ Common Pitfalls and Gotchas

Yes, actors are awesome. But just like with any powerful tool, there are a few footguns you need to watch out for — especially when you first switch from GCD to structured concurrency.

Here’s what to keep in mind:

❗1. You Can’t Access Properties Directly

Unlike classes or structs, you can’t directly access an actor’s properties from the outside. Even reading a simple variable requires a method:

actor Scoreboard {
    var score = 0

    func getScore() -> Int {
        return score
    }
}

// ❌ Not allowed:
let board = Scoreboard()
print(board.score) // Compiler says: Nope 🙅‍♂️

// ✅ Correct way:
Task {
    let currentScore = await board.getScore()
    print(currentScore)
}

Why? Because Swift protects actor’s internal state like Fort Knox 🏰 — only serial access allowed.

❗2. Don’t Forget await!

When calling any async method on an actor from outside, you MUST use await. Otherwise, your app won’t compile, and you’ll find yourself yelling at Xcode (again).

await myActor.doSomething()

Simple rule: Outside the actor? Use await.

Inside the actor? You can call methods normally.

❗3. Deadlocks Are Rare — But Still Possible

Actors prevent data races, but logic errors can still cause deadlocks, especially if you do this:

actor A {
    let b = B()

    func callB() async {
        await b.callA() // 🧨 Deadlock potential!
    }
}

actor B {
    let a = A()

    func callA() async {
        await a.callB()
    }
}

Each actor is waiting for the other to respond = 💥boom.

🧠 _Tip:_ Avoid circular awaits between actors. Think of them like exes — it’s best they don’t call each other.

❗4. Over-Isolating Everything

Don’t turn everything into an actor just because you can. Creating too many actors can:

Actors shine when:

They’re not great for:

❗5. nonisolated Might Surprise You

Sometimes, you want to expose methods or properties without forcing await. Swift lets you do that using nonisolated, but use it with care:

actor Logger {
    nonisolated let version = "1.0.0"
}

Just know: you’re opting out of safety in that case. Don’t nonisolated everything unless you’re absolutely sure it’s thread-safe.

Next, let’s explore when actors actually make sense in real apps, and when you’re better off with a plain ol’ class.

🧩 When Should You Use an Actor?

Now that we know what actors are and how they work, it’s time for the real talk: when should you actually use them in your Swift project?

Spoiler: Not every concurrency problem needs an actor. Sometimes you just need… a class and some chill 😌

✅ Use an Actor When…

  1. You have shared mutable state accessed by multiple concurrent tasks Example: A background sync manager updating a local cache while the UI also reads from it.
  2. You want to avoid writing manual synchronization code Why reinvent GCD wheels and lock mechanisms when Swift gives you a Ferrari? 🏎️
  3. You care about safety more than raw performance Actors are safe, but come with async overhead. It’s a tradeoff you should consciously accept.
  4. You’re building isolated services or managers a. Logging manager b. Download tracker c. User session manager d. Analytics aggregator

❌ Don’t Use an Actor When…

  1. The object is read-only or immutable Immutable structs or let instances don’t need protection — they’re already safe.
  2. You’re dealing with high-performance, tight loops If performance is absolutely critical (e.g., real-time rendering or data crunching), actor overhead may add latency. You’re better off with careful lock-free or concurrent queue design.
  3. You’re trying to isolate UI state UI frameworks like SwiftUI and UIKit aren’t actor-aware, and binding UI to an actor’s async API will drive you nuts 🤯 Use ObservableObject, @State, etc. instead.
  4. You already have well-tested GCD code that works and doesn’t suffer from race conditions Don’t “actor-fy” code just because it’s shiny. If it ain’t broke, maybe don’t fix it. 🛠️

🔄 Actor vs Class vs Struct (Real Talk)

Here’s a human-friendly cheat sheet:

Up next — let’s see how actors play nicely (or not) with other Swift concurrency tools like Task, TaskGroup, and async/await.

🤝 Actors + Other Concurrency Tools

Actors don’t live in a vacuum — they’re part of Swift’s structured concurrency ecosystem. To really unlock their power, you need to know how they collaborate with other tools like async/await, Task, and TaskGroup.

🧬 async/await — The Access Passport

All calls to an actor’s methods from outside its body must use await. Why?

Because Swift ensures that any interaction with an actor happens in a serialized, safe context, which takes time — so it’s asynchronous.

await logger.log("Button tapped")

Inside the actor itself? You can call its own methods without await, because you’re already in the actor’s isolated context. No border control needed 🇸🇬

🏗️ Task — The Entry Point for Actor Use

Actors are often used inside Task closures, especially in async workflows like networking, background syncing, or app startup tasks.

Task {
    await sessionManager.restorePreviousSession()
}

💡 Without Task, there’s no place to await. Think of it as the wrapper that enables async calls.

👯‍♂️ TaskGroup — Parallelism Meets Isolation

TaskGroup lets you spawn multiple child tasks that run in parallel — perfect for concurrent work. Just don’t mutate the same actor from inside multiple children. That still serializes.

Example:

actor Tracker {
    private var events: [String] = []

    func log(_ event: String) {
        events.append(event)
    }
}

let tracker = Tracker()

await withTaskGroup(of: Void.self) { group in
    for i in 1...5 {
        group.addTask {
            await tracker.log("Event \(i)")
        }
    }
}

Even though the tasks run in parallel, each call to log is still serialized inside the actor — no collisions, no crashes. Safe as a Volvo 🚗

💥 Can You Combine Actors With GCD?

Technically? Yes.

Should you? Probably not unless you’re bridging legacy code.

Actors and structured concurrency are designed to replace GCD for most use cases. Mixing them can work, but it’s like doing yoga in scuba gear — not ideal 🧘‍♂️🤿

👉 Want a deeper dive into async/await and Task magic? We’ve got you covered:

**Mastering async/await in Swift: A Beginner’s Guide to Modern Concurrency (Part 1)** _Confused by Swift’s async/await? Learn the basics of async/await, how it improves your Swift code, and how to get…_medium.comhttps://medium.com/swift-pal/mastering-async-await-in-swift-a-beginners-guide-to-modern-concurrency-part-1-88cdb659ac3b

**Mastering async/await in Swift: Real-World Examples for iOS Developers (Part 2)** _Learn how to use async/await in your actual iOS projects — from network calls and image loading to chaining and…_medium.comhttps://medium.com/swift-pal/mastering-async-await-in-swift-real-world-examples-for-ios-developers-part-2-b3e43b1c27e7

Next up — let’s talk about testing. Because what good is concurrency if you can’t write tests without crying? 🧪😅

🧪 Testing Actor-based Code

Good news: actors actually make testing easier than the old days of lock-ridden GCD spaghetti. Why? Because they give you predictable, isolated behavior — perfect for writing clean unit tests.

Let’s see how you can test your actor-powered logic without jumping through async hoops.

✅ 1. Write Async Tests (XCTest Supports It!)

Modern versions of XCTest support async/await, so you can write tests like this:

actor ScoreKeeper {
    private var score = 0

    func add(points: Int) {
        score += points
    }

    func currentScore() -> Int {
        return score
    }
}

Unit test:

func testAddingPoints() async {
    let keeper = ScoreKeeper()

    await keeper.add(points: 10)
    await keeper.add(points: 20)

    let score = await keeper.currentScore()
    XCTAssertEqual(score, 30)
}

Easy. Clean. No dispatch queues. No waiting with expectation.fulfill() 🙌

🔐 2. Don’t Bypass the Actor

Trying to poke into actor internals for testing? 👀 Don’t.

// ❌ Avoid this even in tests
let rawScore = keeper.score // Nope. Compiler won’t let you.

Instead, always test via awaited public APIs. Actors keep their state private for a reason — testing is no excuse to sneak around that.

💡 3. Use nonisolated for Constants or Static Test Data

Let’s say you have version info or static metadata that you want to expose without needing await:

actor AnalyticsManager {
    nonisolated static let sdkVersion = "1.2.3"
}

Then test it like:

XCTAssertEqual(AnalyticsManager.sdkVersion, "1.2.3")

No async needed here — Swift knows this value is safe.

🧪 4. Tip: Test Actor Consumers Too

If your view model or controller depends on an actor — say, SessionActor or Logger — test the interaction. Mock the actor if needed using protocols, or inject a testable variant.

But remember: testing actors directly is often enough, since their state is tightly scoped and easy to control.

Actors make async code testable without pain or guesswork. Just don’t expect them to solve your TDD commitment issues 😅

🧼 Recap: Why Actors Make Swift Safer

By now, you’ve seen it all — the what, why, and how of actors in Swift. So let’s zoom out and quickly review why this feature is more than just syntactic sugar:

✅ What Makes Actors Awesome

🤔 What to Watch Out For

🧠 Final Thought

Actors don’t make your code faster — they make it _safer_ and _saner_.
And sometimes, that’s exactly what you need to build apps that don’t go kaboom 💥 under pressure.

So the next time you’re writing Swift code that juggles state, threads, or tasks — ask yourself:

“Should this be an actor?”

And more often than not, the answer will be: yes, absolutely.

🎉 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