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.

Create a free account to save this page, take notes and track your progress. Sign in

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 @MainActor so 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: .task in a view, async let and task groups for parallel work. Cancellation is cooperative; URLSession and Task.sleep stop when cancelled.
  • Actors protect shared mutable state, such as a cache used by many rows. Inside an actor, an await is 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:

An actor that deduplicates in-flight downloads
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 await in an actor. Another call may have run during the suspension; re-check (as the in-flight dictionary does).
  • Task.detached by default. It drops priority and actor context; use plain Task or 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.

Was this page helpful?