← All topics/Concurrency

Practical interview questions

Scenario-style prompts with sample answer outlines. Focus is on how you would design and reason in real codebases.

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 you are suspended at await, the thread can do other work.

If the task is running on the main actor, every line between await calls, including JSON parsing, image decode, and layout math still executes on the main thread and can drop frames.

The usual mistake is Task { } (or .task) started from @MainActor UI or a @MainActor view model: that child task inherits MainActor, so after each await you resume on the main thread. Network and disk suspend nicely, but the moment you resume, heavy synchronous work runs on main.

The fix is intentional executor hops: run expensive work inside a non-main context, usually a dedicated actor or an async API on a non-main type, and only use await MainActor.run { } (or assignment into @MainActor state from a main-isolated method) for short UI mutations.

Treat 'async' as 'can suspend,' not 'runs in background.'

Principles

  • Main thread must stay roughly under ~16 ms per frame; any synchronous hotspot on the main run loop causes jank.
  • Task { } inherits actor context; inside @MainActor code it is main-bound unless you explicitly leave the actor.
  • await yields the thread but resumes on the same actor unless isolation rules say otherwise.
  • Task.detached leaves the current actor, but it is unstructured concurrency. Use it as an escape hatch, not the default; it does not inherit parent cancellation, task-local values, or lifetime in the same clean way.
  • Verification beats guessing: Time Profiler on Release + device, look at main-thread self weight.
Trap — heavy work after await still on MainActor
@MainActor
final class FeedViewModel: ObservableObject {
    @Published var items: [Item] = []

    func load() {
        Task {
            let data = try await api.fetchFeed()   // suspends — OK
            // Still on MainActor when we resume:
            let decoded = try JSONDecoder().decode([Item].self, from: data) // ❌ can jank
            items = decoded
        }
    }
}
Prefer — 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 MainActor — quick assignment
        }
    }
}
Also good — async helper type, then short MainActor update
struct ThumbnailRenderer {
    // Non-isolated type: this async function hops off @MainActor when awaited
    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

  • Verification: Instruments → Time Profiler → Main Thread → high Self weight; enable Swift concurrency task introspection where useful; strict concurrency complete to catch accidental main isolation.
  • SwiftUI: .task is still tied to view lifetime; if it calls @MainActor view model methods that do CPU work between awaits, you have the same problem—push CPU into a helper actor or non-main async type.
  • If they ask about Task.detached: it can move work off the main actor, but it is unstructured. Prefer a non-main actor/helper first; if you deliberately detach, pass only Sendable data, handle cancellation with Task.checkCancellation(), and await .value when you need the result.
  • If they ask about GCD: DispatchQueue.global().async moves work off main but loses structured cancellation; prefer actors or async helper APIs before dropping down to queues.