How Actors & MainActor Work in Swift
What is an Actor?
An actor is a reference type that protects its mutable state from data races by ensuring only one task can access its properties at a time.
actor ProfilePictureCache {
private var memoryCache: [UUID: UIImage] = [:] // Protected - only one task at a time
func getImage(for userId: UUID) -> UIImage? {
return memoryCache[userId] // Safe access
}
}
Key behavior: When you call a method on an actor from outside, you must await because Swift may need to wait for exclusive access:
// From outside the actor:
let image = await ProfilePictureCache.shared.getImage(for: userId)
// ^^^^^ Required - waits for exclusive access
How Actor Isolation Works
┌─────────────────────────────────────────────┐
│ actor ProfilePictureCache │
│ ┌───────────────────────────────────────┐ │
│ │ private var memoryCache: [...] │ │ ← Protected state
│ │ private var inFlightTasks: [...] │ │
│ └───────────────────────────────────────┘ │
│ │
│ Only ONE task can be inside at a time: │
│ │
│ Task A ──► │ accessing state │ │
│ Task B ──► │ waiting... │ ← Suspended until A finishes
│ Task C ──► │ waiting... │
└─────────────────────────────────────────────┘
From your ProfilePictureCache:
actor ProfilePictureCache {
private var inFlightTasks: [UUID: Task<UIImage?, Error>] = [:]
func downloadAndCacheImage(from urlString: String, for userId: UUID) async -> UIImage? {
// Only one caller can be here at a time
if let task = inFlightTasks[userId] {
return try? await task.value // Wait for existing download
}
// Create new task - safe because we have exclusive access
let task = Task { ... }
inFlightTasks[userId] = task
// ...
}
}
@MainActor is a special global actor that ensures code runs on the main thread. Essential for UI updates.
@MainActor
class FeedViewModel: ObservableObject {
@Published var activities: [Activity] = [] // UI property - must be on main thread
func updateActivities(_ new: [Activity]) {
self.activities = new // Safe - guaranteed main thread
}
}
| Feature |
actor |
@MainActor |
| Thread |
Any background thread |
Main thread only |
| Use case |
Protecting shared state |
UI updates |
| Access |
Exclusive (one at a time) |
Serial on main thread |
| Example |
ProfilePictureCache |
FeedViewModel |
How They Work Together in Your App
┌─────────────────────────────────────────────────────────────────┐
│ Main Thread │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @MainActor FeedViewModel │ │
│ │ @MainActor ActivityCacheService │ │
│ │ @MainActor FriendshipCacheService │ │
│ │ │ │
│ │ All UI updates happen here - SwiftUI requires this │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
│ await
▼
┌─────────────────────────────────────────────────────────────────┐
│ Background Threads │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ actor ProfilePictureCache │ │
│ │ │ │
│ │ Downloads images, manages disk cache │ │
│ │ Thread-safe without blocking main thread │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Practical Examples from Your Codebase
1. Actor protecting shared state (ProfilePictureCache)
actor ProfilePictureCache {
private var memoryCache: [UUID: UIImage] = [:]
// Called from @MainActor views - requires await
func getCachedImageWithRefresh(for userId: UUID, from urlString: String?) async -> UIImage? {
// Check memory cache (thread-safe)
if let cached = memoryCache[userId] {
return cached
}
// Download and cache...
}
}
2. @mainactor for UI state (FeedViewModel)
@MainActor
class FeedViewModel: ObservableObject {
@Published var activities: [FullFeedActivityDTO] = []
func fetchAllData() async {
// This runs on main thread
let fetched = await apiService.fetchActivities()
self.activities = fetched // Safe UI update
}
}
3. Crossing boundaries
@MainActor
class FriendshipCacheService {
func updateFriendsForUser(_ friends: [FullFriendUserDTO], userId: UUID) {
self.friends[userId] = friends // Main thread
// Cross into actor for image work
Task {
await ProfilePictureCache.shared.refreshStaleProfilePictures(for: friends)
// ^^^^^ Crosses from @MainActor → actor (different thread)
}
}
}
Key Rules
-
Actor methods require await from outside
await profilePictureCache.getImage(for: userId)
-
@mainactor methods require await from non-main contexts
Task.detached {
await MainActor.run {
viewModel.updateUI() // Jump to main thread
}
}
-
Inside an actor, no await needed for own properties
actor MyActor {
var data: [String] = []
func addItem(_ item: String) {
data.append(item) // No await - already inside actor
}
}
-
nonisolated escapes actor isolation
actor ProfilePictureCache {
nonisolated private func loadMetadata() {
// Can run on any thread - but can't access actor state
}
}
Why This Matters for Your App
Before actors: You needed manual locks, dispatch queues, and careful synchronization to prevent crashes from concurrent access.
With actors: Swift compiler guarantees thread safety. If you forget await, you get a compile error - not a runtime crash.
// Compile error - forces you to handle concurrency correctly
let image = profilePictureCache.getImage(for: userId)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Error: Expression is 'async' but is not marked with 'await'
How Actors & MainActor Work in Swift
What is an Actor?
An actor is a reference type that protects its mutable state from data races by ensuring only one task can access its properties at a time.
Key behavior: When you call a method on an actor from outside, you must
awaitbecause Swift may need to wait for exclusive access:How Actor Isolation Works
From your
ProfilePictureCache:What is @mainactor?
@MainActoris a special global actor that ensures code runs on the main thread. Essential for UI updates.Actor vs @mainactor Comparison
actor@MainActorProfilePictureCacheFeedViewModelHow They Work Together in Your App
Practical Examples from Your Codebase
1. Actor protecting shared state (ProfilePictureCache)
2. @mainactor for UI state (FeedViewModel)
3. Crossing boundaries
Key Rules
Actor methods require
awaitfrom outside@mainactor methods require
awaitfrom non-main contextsInside an actor, no
awaitneeded for own propertiesnonisolatedescapes actor isolationWhy This Matters for Your App
Before actors: You needed manual locks, dispatch queues, and careful synchronization to prevent crashes from concurrent access.
With actors: Swift compiler guarantees thread safety. If you forget
await, you get a compile error - not a runtime crash.