← All topics/Concurrency

Question 2

Keeping expensive work off the main thread

You notice UI jank when performing async work. How do you guarantee expensive work never blocks the main thread in a Swift async/await system?

Follow-ups

  • How do you verify this in practice?

Answer outline

Async/await removes blocking waits. While a task is suspended at an await, the thread is free to do other work. Every line between one await and the next still runs wherever the task is isolated, and on the main actor that means the main thread.

The usual mistake is a Task { } or .task started from @MainActor UI or a @MainActor view model. That task inherits the main actor, so after each await it resumes on the main thread. The network call itself suspends, but the JSON decode or image work that follows runs on the main thread and drops frames.

The fix is an intentional executor hop. Run the expensive work in a non-main context, usually a dedicated actor or an async method on a non-isolated type. Come back to the main actor only for the short UI mutation.

Treat async as meaning 'can suspend'. It says nothing about which thread the work runs on. That depends on the actor the task is isolated to.

Principles

  • A frame budget is about 16 ms at 60 Hz and 8 ms at 120 Hz, so synchronous hotspots cause jank.
  • Task { } inherits the actor context, so inside @MainActor code it's main-bound unless you explicitly leave the actor.
  • await yields the thread but resumes on the same actor as the enclosing function, so what follows runs wherever that function is isolated.
  • Task.detached leaves the current actor but is unstructured, so treat it as an escape hatch rather than the default.
  • Verify with Time Profiler using a Release build on a real device, and look at main-thread self weight.
Problem: heavy work after await still runs on the main actor
@MainActor
final class FeedViewModel: ObservableObject {
    @Published var items: [Item] = []

    func load() {
        Task {
            let data = try await api.fetchFeed() // suspends, which is fine
            // Problem: still on the main actor when we resume
            let decoded = try JSONDecoder().decode([Item].self, from: data) // can jank
            items = decoded
        }
    }
}
Fix: move CPU work into a non-main actor
actor FeedDecoder {
    func decode(_ data: Data) throws -> [Item] {
        try JSONDecoder().decode([Item].self, from: data)
    }
}

@MainActor
final class FeedViewModel: ObservableObject {
    @Published var items: [Item] = []
    private let decoder = FeedDecoder()

    func load() {
        Task {
            let data = try await api.fetchFeed()
            let decoded = try await decoder.decode(data)
            items = decoded // back on the main actor for a quick assignment
        }
    }
}
Alternative: async helper type, then a short main actor update
struct ThumbnailRenderer {
    // Non-isolated async function: awaited from the main actor, it runs off the main thread
    // (with Swift 6.2's NonisolatedNonsendingByDefault enabled, mark it @concurrent)
    func thumbnail(from data: Data) async -> UIImage? {
        UIImage(data: data)?.preparingThumbnail(of: CGSize(width: 120, height: 120))
    }
}

@MainActor
func refreshThumbnail(from url: URL, renderer: ThumbnailRenderer) {
    Task {
        let (data, _) = try await URLSession.shared.data(from: url)
        let image = await renderer.thumbnail(from: data)
        thumbnailView.image = image
    }
}

Follow-up angles

  • To verify, run Time Profiler in Instruments, select the main thread, and sort by self weight. Turning strict concurrency checking up to complete also surfaces isolation mistakes at compile time, such as touching main-actor state from a background context.
  • SwiftUI's .task is tied to the view's lifetime, but it still runs on the main actor. If it calls @MainActor view model methods that do CPU work between awaits, you have the same problem. Push the CPU work into a helper actor or a non-main async type.
  • Task.detached moves work off the main actor but is unstructured, and DispatchQueue.global().async loses structured cancellation. Prefer a non-main actor or async helper first, and if you do detach, pass only Sendable data and check cancellation as you go.