Introduction
Concurrent programming in Swift has come a long way. Before async/await and structured concurrency, managing shared mutable state across threads was a minefield of race conditions and crashes. Actors in Swift: Swift & iOS best practices explained is at the heart of Swift's modern concurrency model, providing compile-time safety guarantees that traditional locks simply cannot offer. In this guide, we'll walk through how actors protect their state from data races, how to access them safely with await, when to reach for @MainActor, and how Sendable types keep your concurrency boundaries clean.
Actors protect their state from data races
The core promise of actors is straightforward: they isolate mutable state. Only one task can access an actor's properties at a time, eliminating data races by design rather than by convention.
Consider a real-world scenario: we're building a ride-hailing app and need to track a driver's current status. Multiple background tasks might try to update location, passenger assignment, and earnings simultaneously. Without an actor, we'd need locks, atomic types, or careful synchronization—all error-prone.
actor DriverProfile {
var location: CLLocationCoordinate2D
var isAvailable: Bool
var passengerID: String?
var totalEarnings: Double
init(location: CLLocationCoordinate2D) {
self.location = location
self.isAvailable = true
self.passengerID = nil
self.totalEarnings = 0
}
func updateLocation(_ newLocation: CLLocationCoordinate2D) {
self.location = newLocation
}
func assignPassenger(id: String) {
self.passengerID = id
self.isAvailable = false
}
func completeRide(fare: Double) {
self.passengerID = nil
self.isAvailable = true
self.totalEarnings += fare
}
}The actor keyword provides mutual exclusion automatically. When one task calls updateLocation, another task calling completeRide must wait its turn. The compiler enforces this—you cannot access actor properties directly from outside; you must use await. This makes data races impossible by construction.
Access with await from outside
Accessing an actor's properties or methods from outside always requires await. This isn't just syntactic sugar; it signals to the developer that a potential suspension point exists. The compiler will reject any attempt to access actor state without it.
Let's say we have a ride-matching service that needs to read driver availability:
class RideMatchingService {
let driver: DriverProfile
init(driver: DriverProfile) {
self.driver = driver
}
func findDriverForPassenger() async -> Bool {
// Must use await to access actor properties
let isAvailable = await driver.isAvailable
if isAvailable {
let currentLocation = await driver.location
print("Driver available at \(currentLocation)")
return true
}
return false
}
func assignRide(passengerID: String) async {
// Mutating method calls also require await
await driver.assignPassenger(id: passengerID)
}
}The await keyword means the current task may suspend while the actor serializes access. This is safe—the actor guarantees no other task modifies state while you're reading or writing. From an operational perspective, tasks queue up; the actor processes them one at a time, then resumes the waiting task.
In production e-commerce systems, we've seen this pattern prevent inventory race conditions. Multiple order services might reserve stock simultaneously, but the inventory actor ensures each reservation is atomic and consistent.
@MainActor for UI code
UIKit and SwiftUI require all UI updates to run on the main thread. Rather than sprinkling DispatchQueue.main.async throughout your codebase, Swift gives us @MainActor—a specialized actor that automatically executes on the main thread.
Let's build a ride status view model that updates the UI:
@MainActor
class RideStatusViewModel: ObservableObject {
@Published var driverName: String = ""
@Published var estimatedArrival: TimeInterval = 0
@Published var driverLocation: CLLocationCoordinate2D = CLLocationCoordinate2D()
private let rideService: RideService
init(rideService: RideService) {
self.rideService = rideService
}
func updateRideStatus() async {
do {
let rideInfo = try await rideService.fetchCurrentRide()
// These assignments are already on the main thread
self.driverName = rideInfo.driverName
self.estimatedArrival = rideInfo.eta
self.driverLocation = rideInfo.location
} catch {
print("Error updating ride status: \(error)")
}
}
}By marking the entire class with @MainActor, every method and property access automatically runs on the main thread. This means @Published properties trigger view updates reliably, and there's no risk of accidentally updating UI from a background thread.
You can also apply @MainActor to individual methods:
class DataProcessor {
@MainActor
func updateUI(with data: String) {
// This method runs on the main thread
}
func processData() async {
// This runs on whatever thread the caller provides
}
}In our experience with travel SaaS platforms, @MainActor has been invaluable for preventing subtle threading bugs that only surface in production under load.
Sendable types cross concurrency boundaries
When we move data between actors or into background tasks, Swift must verify that data is safe to share. The Sendable protocol marks types as safe for concurrent access.
Value types (structs, enums) are Sendable by default because they're copied, not shared. Reference types must explicitly conform:
// Value type: automatically Sendable
struct Location: Sendable {
var latitude: Double
var longitude: Double
}
// Reference type: must explicitly conform
final class RideRequest: Sendable {
let passengerID: String
let pickupLocation: Location
let dropoffLocation: Location
init(passengerID: String, pickup: Location, dropoff: Location) {
self.passengerID = passengerID
self.pickupLocation = pickup
self.dropoffLocation = dropoff
}
}The compiler verifies that Sendable types contain no mutable shared state. If you try to add a mutable property to a Sendable class, the compiler rejects it. This forces you to design immutable data structures for cross-concurrency communication.
In practice, here's how Sendable types flow through a ride-hailing workflow:
actor RideCoordinator {
func createRide(request: RideRequest) async {
// RideRequest is Sendable, safe to store and pass between tasks
let ride = await assignDriver(to: request)
await notifyPassenger(ride: ride)
}
private func assignDriver(to request: RideRequest) async -> RideRequest {
// Safe to use request here; it's Sendable
return request
}
@MainActor
private func notifyPassenger(ride: RideRequest) {
// Ride request crosses from RideCoordinator actor to MainActor
// Compiler ensures this is safe because RideRequest is Sendable
print("Notifying passenger: \(ride.passengerID)")
}
}Common mistakes
We've seen several patterns that trip up teams new to actors:
- Forgetting
awaiton actor calls. The compiler will catch this, but it's easy to miss when refactoring. Always ensure your IDE showsawaitdiagnostics. - Treating actors like normal classes. You cannot store a reference to actor state outside the actor. Each access must go through the actor's interface with
await. - Making actor methods throw without reason. Every async call has overhead. If a method cannot fail, don't make it throwing; keep the interface clean.
- Ignoring Sendable compliance. If you pass a non-Sendable type across concurrency boundaries, the compiler will reject it. Design data structures for concurrency from the start.
- Over-using @MainActor. Not everything needs to run on the main thread. Use
@MainActoronly for classes that genuinely need main-thread semantics—usually view models and UI controllers.
Key takeaways
- Actors provide compile-time data-race safety by isolating mutable state and ensuring only one task accesses it at a time.
- Access actor properties and methods from outside using
await, which signals a potential suspension point. @MainActoris a specialized actor for code that must run on the main thread; apply it to view models and UI controllers.- Sendable types—value types and carefully designed reference types—cross concurrency boundaries safely; the compiler enforces this.
- Design your data structures and workflows around concurrency constraints from the beginning; retrofitting actors into legacy code is harder than building with them.






Comments
Loading comments…