Is moving work off the main actor free, and what does it cost to hop back to it many times?

21.10.2 · L5 · Interview

Predict first

Compare 20,000 separate main-actor hops with one hop doing the same work.

import Foundation

final class Counter: @unchecked Sendable { @MainActor var n = 0 }
let counter = Counter()
@MainActor func bump() { counter.n += 1 }
let n = 20_000
let clock = ContinuousClock()
let perHop = await Task.detached { () -> Duration in
    await clock.measure { for _ in 0..<n { await bump() } }
}.value
let oneHop = await Task.detached { () -> Duration in
    await clock.measure { await MainActor.run { for _ in 0..<n { counter.n += 1 } } }
}.value
print("counter:", await counter.n)
print("20,000 separate hops finish in under 2 s:", perHop < .seconds(2))
print("one hop doing the same work is more than 5x faster:", perHop / oneHop > 5)
  • counter 40000 / separate hops under 2 s: true / one hop more than 5x faster: true
  • counter 20000 / one hop is slower than separate hops
  • The separate hops take over 2 s

Mental model. A courier trip to the office per document versus one trip with a folder. The trip is not expensive, but 20,000 of them are.

Calling a @MainActor function from another task is a suspension plus an enqueue: the caller gives up its thread, the work is queued on the main executor, and the caller resumes later (possibly on a different thread). In the check, 20,000 separate await bump() calls from a detached task took about a fifth to a third of a second in total on the machine that produced this answer, roughly ten microseconds per hop, and the whole run was asserted only to finish in under two seconds. A single hop that performed the same 20,000 increments took microseconds: more than five times faster (in practice far more). A hop is cheap compared with a network call and expensive compared with an increment, and each one also gives other main-actor work a chance to run in between.

So treat hops as something to batch. Compute off the main actor, collect the result, and apply it in one hop (one state update, one @Published change, one snapshot apply), instead of hopping per item or per progress tick. This also reduces SwiftUI invalidations (Q433). The opposite mistake is staying on the main actor for heavy work to avoid hops: that is a hang (Q287). Choose by the work: tens of microseconds of formatting stays where it is, parsing a megabyte goes off, and the result comes back once. Not asserted: exact per-hop times, which depend on load, the machine and the Swift runtime version; the check uses generous ratios.

Each hop suspends and enqueues; one hop that does all the work avoids the repeated cost.

Common traps

  • Calling a main-actor method inside a loop from a background task. Every iteration suspends.
  • Moving everything to the main actor to avoid thinking about hops. That is how hangs are made.

They might follow up with

  • Does MainActor.run cost the same? It is the same hop; one hop with a loop inside is the batched form.
  • How do I see hops? The Swift Concurrency instrument shows tasks, suspensions and actor queues.

Related questions

  • 21.10.1 How can an app detect its own main-thread hangs, and what are the limits of doing it in-process?
  • 9.7.1 What is a hitch versus a hang, how does ProMotion change the budget, and how do you measure and fix them?
  • 21.4.1 What makes a SwiftUI body run again, how do you see it, and how do you reduce unnecessary invalidation?
  • 21.2.1 Which Instruments templates do you reach for, how do you read a Time Profiler trace, and where can a sampling…

← How can an app detect its own main-thread hangs, and what are the…