How to Use Swift Actors to Prevent Data Races and Synchronize State Access in Concurrent iOS Applications

In modern iOS application architecture, managing concurrent state across multiple threads is a notoriously complex endeavor. When a background URLSession task attempts to write JSON payload data to a shared cache while the main UI thread simultaneously attempts to read that same cache to render a UITableView, a classic data race occurs. This leads to unpredictable crashes, memory corruption (EXC_BAD_ACCESS), and corrupted application state. Historically, iOS engineers mitigated these data races by wrapping shared state in manual Grand Central Dispatch (GCD) serial queues or low-level NSLock mutexes. With the introduction of Swift 5.5, Apple revolutionized concurrency by introducing Actors—a powerful, compiler-enforced paradigm that mathematically guarantees synchronized, thread-safe access to mutable state without the boilerplate of manual locking.

The Anatomy of a Swift Actor

An actor in Swift is a reference type (like a class). However, unlike a standard class, an actor inherently isolates its mutable state from the rest of the application. The Swift compiler mathematically enforces a strict rule: only one execution thread can access the actor’s internal properties and methods at any given nanosecond.

Consider a traditional, highly dangerous class managing a network token cache:

// DANGEROUS: Susceptible to data races if accessed concurrently
class TokenManager {
    var sessionToken: String?
    
    func updateToken(newToken: String) {
        self.sessionToken = newToken
    }
}

To secure this state, you simply change the keyword from class to actor:

// SECURE: State is mathematically isolated
actor TokenManager {
    var sessionToken: String?
    
    func updateToken(newToken: String) {
        self.sessionToken = newToken
    }
}

Compiler-Enforced State Isolation

The brilliance of the actor model lies in compiler enforcement. If you attempt to access an actor’s state directly from outside the actor (e.g., from a View Controller), the Swift compiler will immediately throw a fatal build error: “Actor-isolated property ‘sessionToken’ can not be mutated from a non-isolated context.”

To interact with an actor, you must cross the “actor isolation boundary.” Because the actor guarantees serial execution, any external request must patiently wait in a queue if the actor is currently busy processing another thread’s request. You achieve this by utilizing the await keyword.

let manager = TokenManager()

// Execute a concurrent task
Task {
    // The thread will suspend here until the actor is free
    await manager.updateToken(newToken: "eyJhbGciOiJIUzI1...")
    
    // Read the token safely
    let currentToken = await manager.sessionToken
    print(currentToken ?? "No token")
}

Solving the Reentrancy Problem

While actors prevent simultaneous data races, they introduce a subtle architectural challenge known as actor reentrancy. When an actor executes a method that contains an await call (e.g., making a network request), the actor explicitly yields its execution thread. During this suspension, the actor is “unlocked,” and it can begin processing other queued messages from different threads.

Consider this flawed image caching actor:

actor ImageCache {
    var cache: [String: UIImage] = [:]
    
    func fetchImage(url: String) async -> UIImage {
        // 1. Check if the image exists
        if let cached = cache[url] { return cached }
        
        // 2. AWAIT: The actor yields execution here!
        let image = await downloadImage(from: url) 
        
        // 3. The state may have changed while suspended!
        cache[url] = image 
        return image
    }
}

If two concurrent UI threads request the exact same URL simultaneously, Thread A will hit the await downloadImage line and suspend. The actor is now free. Thread B enters the function, sees the cache is still empty, and also initiates a redundant network download. Both threads overwrite the cache when they resume.

To resolve reentrancy, you must design your actor state to track in-flight asynchronous operations, not just the final result:

actor ImageCache {
    var cache: [String: UIImage] = [:]
    // Track ongoing download tasks
    var activeDownloads: [String: Task<UIImage, Never>] = [:]
    
    func fetchImage(url: String) async -> UIImage {
        if let cached = cache[url] { return cached }
        
        // If a download is already running, wait for its result instead of starting a new one
        if let existingTask = activeDownloads[url] {
            return await existingTask.value
        }
        
        // Create a new async task and store it
        let downloadTask = Task {
            return await downloadImage(from: url)
        }
        
        activeDownloads[url] = downloadTask
        
        let image = await downloadTask.value
        
        // Safely update state
        activeDownloads[url] = nil
        cache[url] = image
        
        return image
    }
}

By intelligently tracking the Task objects within the isolated actor state, iOS engineers can eliminate redundant network overhead, eradicate fatal EXC_BAD_ACCESS crashes, and construct robust, highly concurrent mobile applications that strictly adhere to Swift’s memory safety guarantees.

Get the best tech tips delivered straight to your inbox.

Join thousands of readers mastering Apple, Google, Microsoft, and Linux.