Managing State in Swift: Balancing Actors and Structured Concurrency
Learn how to combine Swift Actors and Structured Concurrency to eliminate data races in network-heavy applications while maintaining efficient resource management.
11 Jan 2026, 20:25 UTC

The Problem: The 'Data Race' Anxiety
When building network-heavy Swift applications, the primary challenge isn't making the request—it's managing the result. Updating a shared cache or a UI state from multiple concurrent network calls often leads to data races, where two threads attempt to modify the same memory simultaneously, causing unpredictable crashes or corrupted state.
The takeaway: Use Actors to protect shared mutable state and Structured Concurrency (Tasks) to manage the lifecycle of the requests. By isolating state within an actor, you shift the burden of synchronization from manual locks to the Swift compiler.
Isolating State with Actors
An actor is a reference type similar to a class, but with a critical difference: it ensures that only one task can access its mutable state at a time. This is known as actor isolation.
When you call a method on an actor from the outside, you must use the await keyword. This signals a potential suspension point, meaning the calling thread can be freed up to do other work while the actor processes the request in its own serialized queue.
Orchestrating Work with Structured Concurrency
Swift's structured concurrency allows you to spawn child tasks that are logically tied to a parent. If a parent task is cancelled (for example, when a user navigates away from a screen), all child tasks are automatically notified of the cancellation.
This prevents "zombie" network requests that continue to consume battery and bandwidth after their results are no longer needed. However, cancellation is cooperative. This means your code must actively check if it has been cancelled to stop execution.
Worked Example: A Thread-Safe Image Cache
Below is a practical implementation of a cache that prevents redundant network calls and protects its internal dictionary using an actor. This example assumes Swift 5.5+.
// The actor ensures only one task modifies the cache at a time
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func getImage(for url: URL) async throws -> UIImage {
// Return cached image if available
if let cached = cache[url] {
return cached
}
// Simulate network fetch
let image = try await downloadImage(from: url)
// Update state safely within the actor
cache[url] = image
return image
}
private func downloadImage(from url: URL) async throws -> UIImage {
// In a real app, use URLSession.shared.data(from:)
try await Task.sleep(nanoseconds: 1 * 1_000_000_000)
return UIImage()
}
}
Implementing the Request Logic
To use this cache within a UI context, use a Task to bridge the synchronous world of a View to the asynchronous world of the actor. Run this in your View Controller or ViewModel:
func loadImage() {
// Create a structured task
let task = Task {
do {
let cache = ImageCache()
let url = URL(string: "https://example.com/image.png")!
// Accessing the actor requires 'await'
let image = try await cache.getImage(for: url)
// Switch back to the main thread for UI updates
await MainActor.run {
self.imageView.image = image
}
} catch {
print("Fetch failed: \(error)")
}
}
// To cancel the request if the view disappears:
// task.cancel()
}
Trade-offs and Limitations
While actors eliminate data races, they introduce Actor Reentrancy. When a task awaits a call inside an actor, the actor is unblocked. Other tasks can enter the actor and change the state before the first task resumes.
The Risk: If you check a condition, await a network call, and then assume the condition is still true, you may be wrong. Always re-verify your state after an await call if the state could have been modified by another task in the interim.
Verification and Result Check
To verify that your actor is providing isolation, you can attempt to modify an actor's property directly from a Task without using await. The Swift compiler will throw a compile-time error: "Actor-isolated property 'cache' can only be referenced from inside the actor".
To check for cooperative cancellation in long-running loops, insert try Task.checkCancellation(). If the task was cancelled, this will throw a CancellationError, allowing you to exit the operation cleanly.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.