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@MainActorcode it's main-bound unless you explicitly leave the actor.awaityields the thread but resumes on the same actor as the enclosing function, so what follows runs wherever that function is isolated.Task.detachedleaves 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.
@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
}
}
}
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
}
}
}
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
completealso surfaces isolation mistakes at compile time, such as touching main-actor state from a background context. - SwiftUI's
.taskis tied to the view's lifetime, but it still runs on the main actor. If it calls@MainActorview 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.detachedmoves work off the main actor but is unstructured, andDispatchQueue.global().asyncloses structured cancellation. Prefer a non-main actor or async helper first, and if you do detach, pass onlySendabledata and check cancellation as you go.



