iOS with SwiftUI · Navigation, Networking, Persistence and Concurrency
Swift Concurrency in an App: MainActor, Tasks and Actors
Keep the UI responsive and free of data races: the main actor for UI state, tasks and cancellation, actors for shared caches, and what the Swift 6 language mode checks for you.
What you'll build
An image loader for a list of products that downloads thumbnails concurrently, never downloads the same image twice even when rows ask at the same moment, cancels work for rows scrolled off screen, and updates the UI only on the main actor.
The idea
- The main actor runs UI code. Mark view models
@MainActorso their state is only touched there. In new app projects Xcode can make the main actor the default for the app module (Swift 6.2's approachable concurrency), so you opt out for background work rather than in. - Tasks:
.taskin a view,async letand task groups for parallel work. Cancellation is cooperative;URLSessionandTask.sleepstop when cancelled. - Actors protect shared mutable state, such as a cache used by many rows. Inside an actor, an
awaitis a suspension point where other calls can run, so re-check state after awaiting (actor re-entrancy). - Swift 6 language mode turns data-race checks into compile errors: values crossing actors must be
Sendable.
The code
actor ImageLoader {
private var cache: [URL: Data] = [:]
private var inFlight: [URL: Task<Data, Error>] = [:]
func data(for url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
if let running = inFlight[url] { return try await running.value } // join the existing download
let task = Task { try await URLSession.shared.data(from: url).0 }
inFlight[url] = task
defer { inFlight[url] = nil }
let data = try await task.value
cache[url] = data
return data
}
}
struct Thumbnail: View {
let url: URL
@Environment(ImageLoaderBox.self) private var loader
@State private var image: UIImage?
var body: some View {
Group {
if let image { Image(uiImage: image).resizable().scaledToFill() }
else { Color.secondary.opacity(0.1) }
}
.frame(width: 56, height: 56)
.clipShape(.rect(cornerRadius: 8))
.task(id: url) { // cancelled when the row scrolls away
if let data = try? await loader.actor.data(for: url) { image = UIImage(data: data) }
}
}
}
Try the logic
The deduplicating loader running for real: 40 rows request 4 images at once, and each image is downloaded exactly once:
actor ImageLoader {
private var cache: [String: String] = [:]
private var inFlight: [String: Task<String, Never>] = [:]
private(set) var downloads = 0
func image(for url: String) async -> String {
if let cached = cache[url] { return cached }
if let running = inFlight[url] { return await running.value }
downloads += 1
let task = Task {
try? await Task.sleep(for: .milliseconds(50)) // pretend download
return "<image \(url)>"
}
inFlight[url] = task
let image = await task.value
cache[url] = image
inFlight[url] = nil
return image
}
}
let loader = ImageLoader()
let urls = (0..<40).map { "https://cdn.example.com/p\($0 % 4).jpg" }
let loaded = await withTaskGroup(of: String.self) { group in
for url in urls { group.addTask { await loader.image(for: url) } }
var count = 0
for await _ in group { count += 1 }
return count
}
print("\(loaded) rows got an image")
print("downloads: \(await loader.downloads)")
_ = await loader.image(for: urls[0])
print("after a cache hit, downloads: \(await loader.downloads)")
Output
40 rows got an image downloads: 4 after a cache hit, downloads: 4
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Heavy work on the main actor. Decoding large JSON or resizing images there freezes scrolling; move it to a non-isolated async function or an actor.
- Assuming state is unchanged after
awaitin an actor. Another call may have run during the suspension; re-check (as the in-flight dictionary does). Task.detachedby default. It drops priority and actor context; use plainTaskor structured concurrency unless you have a reason.- Silencing Sendable errors with
@unchecked Sendable. Fix the design (value types, actors) instead.
Key points
- UI state lives on the
@MainActor; slow work runs off it. .task(id:)ties work to a view and cancels it automatically.- Actors protect shared mutable state; remember re-entrancy at each
await. - The Swift 6 language mode catches data races at compile time.
Further reading: Concurrency in the Swift book, Adopting strict concurrency in Swift 6 apps.
Practice questions
What is actor re-entrancy and why does it matter?
Answer
While an actor method is suspended at an await, the actor can run other calls. State read before the await may have changed when the method resumes, so code must re-check assumptions after each await, for example by recording in-flight work so concurrent callers share it.
Why should view models be @MainActor?
Answer
SwiftUI reads their state on the main thread, so updating it from background threads causes data races and runtime warnings. @MainActor guarantees all access happens on the main actor, and the compiler enforces it, while async work they await can still run elsewhere.
Open in the app to mark lessons complete, take notes and track your progress.