Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 012 / 100

Actors in Swift: Swift & iOS best practices explained

Safe shared mutable state.

Expona Admin7 min read

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.

swift
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:

swift
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:

swift
@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:

swift
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:

swift
// 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:

swift
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 await on actor calls. The compiler will catch this, but it's easy to miss when refactoring. Always ensure your IDE shows await diagnostics.
  • 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 @MainActor only 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.
  • @MainActor is 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles