Preventing Data Races in Swift with Actors and Structured Concurrency
Learn how to eliminate data races in Swift using Actors, @MainActor, and structured concurrency to manage shared state safely in asynchronous environments.
20 May 2026, 04:00 UTC

The Problem: Shared Mutable State in Async Code
In asynchronous Swift applications, multiple threads often attempt to modify the same piece of data simultaneously. This creates a data race, a condition where the final state of the data depends on the unpredictable timing of thread execution, often leading to crashes or corrupted memory. Traditional solutions like DispatchQueue or NSLock are prone to deadlocks and require manual management of locking logic.
The solution is to use Actors and Structured Concurrency. Actors provide a synchronization mechanism that ensures only one task can access their internal state at a time, while async/await allows you to handle these requests linearly without blocking the execution thread.
Prerequisites
- Xcode 13+ and Swift 5.5+
- A project targeting iOS 13+, macOS 10.15+, watchOS 6+, or tvOS 13+
- Knowledge of basic Swift closures and error handling
Implementing a Thread‑Safe State Manager
To protect a shared resource, define it as an actor rather than a class. An actor is a reference type that automatically serializes access to its properties.
// This actor protects the shared state of a simple image cache
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func save(_ image: UIImage, for url: URL) {
cache[url] = image
}
func image(for url: URL) -> UIImage? {
return cache[url]
}
}
Consuming Actor State with Async/Await
Because actors protect their state, any call to an actor’s method from the outside must be asynchronous. You must use the await keyword, which signals a potential suspension point where the current task pauses to let the actor process the request.
struct ImageDownloader {
let cache = ImageCache()
func fetchImage(from url: URL) async throws -> UIImage {
// Check cache first (requires await because cache is an actor)
if let cached = await cache.image(for: url) {
return cached
}
// Perform network request
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { throw CacheError.invalidData }
// Save to cache (requires await)
await cache.save(image, for: url)
return image
}
}
Handling UI Updates with @MainActor
Updating the user interface from a background task will cause a crash or erratic behavior. The @MainActor attribute ensures that a class, method, or property is always accessed on the main thread.
@MainActor
class ImageViewModel: ObservableObject {
@Published var currentImage: UIImage? = nil
private let downloader = ImageDownloader()
func loadImage(url: URL) async {
do {
let image = try await downloader.fetchImage(from: url)
// This update is safe because the class is marked @MainActor
self.currentImage = image
} catch {
print("Failed to load image: \(error)")
}
}
}
Parallel Execution via Structured Concurrency
When you need to perform multiple asynchronous tasks simultaneously—such as downloading several images—use async let or TaskGroup. This ensures that if the parent task is cancelled, all child tasks are also cancelled.
func loadGallery(urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in urls {
group.addTask {
// Each task runs in parallel
return try await self.downloader.fetchImage(from: url)
}
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
}
Critical Decision: Managing Actor Reentrancy
A common pitfall is actor reentrancy. When an actor hits an await point, it suspends its current execution, allowing other tasks to run. This means the actor’s state may change while a method is suspended.
| Scenario | Risk | Mitigation |
|---|---|---|
| Checking a value, then awaiting a network call, then updating that value. | The value may have been changed by another task during the await. | Re‑verify the state immediately after the await call before applying changes. |
| Updating UI from a background Task. | Main thread violation/crash. | Wrap the update in MainActor.run { ... } or mark the function @MainActor. |
Verification and Diagnostics
To ensure your implementation is truly race‑free, use the following checks:
- Strict Concurrency Checking: In Xcode, go to Build Settings > Swift Compiler - Warnings and set
Strict Concurrency CheckingtoComplete. This forces the compiler to warn you about potential data races at compile time. - Thread Sanitizer (TSan): Edit your Scheme (
Cmd + <), go to the Diagnostics tab, and enableThread Sanitizer. Run the app; TSan will pause execution and highlight the exact lines of code where a data race occurs. - Main Thread Assertion: To verify
@MainActoris working, you can adddispatchPrecondition(condition: .onQueue(.main))inside your UI update methods.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.