@MainActor and Dispatchers.Main are two different promises
Both mean the main thread and neither knows about the other.
TL;DR
- A Kotlin callback that arrives on
Dispatchers.Mainand writes to an@Publishedproperty compiles with Swift 6 strict concurrency as "call to main actor-isolated property in a synchronous nonisolated context" — because Swift's checker has no way to know the thread. - Both mean "the main thread".
@MainActoris a compile-time guarantee verified by the Swift compiler;Dispatchers.Mainis a runtime fact the Swift compiler cannot observe. Neither satisfies the other. - The fix is a single explicit hop at the boundary —
MainActor.assumeIsolatedwhere the invariant is genuinely held, andTask { @MainActor in }where it is not — and knowing which took a measurement. - What reading on buys: why the two systems cannot see each other, when
assumeIsolatedis safe versus a crash waiting, and the thread-identity probe that settles it.
The case this post is about
A Kotlin controller delivering state on the main dispatcher:
// commonMain/kotlin/feed/FeedController.kt
class FeedController(private val repo: FeedRepo) {
// Main.immediate, so a callback from a main-thread caller runs synchronously rather than
// being posted — which is the behaviour the UI wants and is central to what follows.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
fun observe(onState: (FeedState) -> Unit): Cancellable {
onState(state.value)
val job = scope.launch { state.collect { onState(it) } }
return Cancellable { job.cancel() }
}
}
// iosApp/Sources/FeedModel.swift
@MainActor
final class FeedModel: ObservableObject {
@Published var state: FeedState = .loading
private var subscription: Cancellable?
init(controller: FeedController) {
subscription = controller.observe { [weak self] newState in
self?.state = newState // ← here
}
}
}
The symptom
With Swift 6 language mode enabled:
error: call to main actor-isolated property 'state' in a synchronous nonisolated context
FeedModel.swift:12:19
error: capture of 'self' with non-sendable type 'FeedModel?' in a '@Sendable' closure
FeedModel.swift:11:40
Two errors on a closure that provably runs on the main thread. And in Swift 5 language mode the same code produced a warning that had been ignored for months:
warning: capture of 'self' with non-sendable type 'FeedModel?' in a '@Sendable' closure;
this is an error in the Swift 6 language mode
The code worked. It had always worked — the callback does arrive on the main thread, every time. The compiler was not wrong either: nothing in the type system says so.

Why the obvious fix failed
The first fix was @unchecked Sendable, which silences it:
final class FeedModel: ObservableObject, @unchecked Sendable { … }
That is an assertion that the type is safe to share across concurrency domains, which FeedModel
is not — it has mutable state with no synchronisation. It compiles and removes the one check that
would have caught a genuine misuse later, which is a bad trade for a build-error fix.
The second fix was to wrap every callback body in a Task:
subscription = controller.observe { [weak self] newState in
Task { @MainActor in self?.state = newState }
}
Compiles, correct, and it changes the timing. Dispatchers.Main.immediate delivers
synchronously when already on main; a Task always suspends and resumes on the next main-actor
turn. Measured on a scroll-driven update that was arriving at 60 Hz:
direct assignment state visible in the same frame
Task { @MainActor in … } state visible 1–2 frames later, and coalesced
One frame of latency per update, and SwiftUI coalescing two updates into one — which on a progress indicator looked like it was skipping values.
The third attempt was to make the Kotlin side dispatch to the main actor, which is not a thing Kotlin can express:
// there is no Dispatchers.MainActor
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
Dispatchers.Main on iOS dispatches to the main queue. The main actor is also backed by the main
queue, so the thread is right — and "runs on the thread the main actor uses" is not the same
statement as "is main-actor isolated", and only the second one is a type-level fact.
The actual mechanism
Two concurrency systems, both correct, with no shared vocabulary.
@MainActor is a compile-time isolation domain. The Swift compiler tracks which actor each
function and property belongs to and rejects a cross-domain access that is not awaited. The
guarantee is static: the checker proves the access happens in the right domain, and the runtime
assertion is a consequence rather than the mechanism.
Dispatchers.Main is a runtime dispatch target. The coroutine machinery posts to the main
queue (or runs inline, with .immediate, if already there). The guarantee is dynamic and real —
the code genuinely executes on the main thread — and there is no artefact the Swift compiler can
inspect to learn it.
The exported closure is @Sendable by necessity. Kotlin's exported function type becomes an
Obj-C block, and Swift must assume a block can be called from anywhere, because nothing in the
Obj-C type system constrains it. So the closure is nonisolated, and a nonisolated synchronous
context cannot touch main-actor state.
Which means the invariant is true and unprovable. That is the whole situation, and it has exactly two honest resolutions:
the callback IS always on main → assert it: MainActor.assumeIsolated { }
zero cost, and CRASHES if ever false
the callback MIGHT not be → hop: Task { @MainActor in }
one frame of latency, always safe
assumeIsolated is not a silencer — it is a runtime precondition. It compiles to an assertion that
the current execution context is the main actor, and traps if not. So it converts an unprovable
invariant into a checked one, which is a genuinely different thing from @unchecked Sendable,
and the difference is that a violation is a loud crash rather than silent data corruption.
And whether the invariant holds depends on the Kotlin side, which is the part that needed measuring rather than asserting.

The fix
One boundary adapter, with assumeIsolated where the dispatcher guarantees it and a hop where it
does not — and the Kotlin side made explicit about which it provides.
// commonMain/kotlin/feed/FeedController.kt
class FeedController(private val repo: FeedRepo) {
/**
* Main.immediate, documented as part of the contract rather than an implementation detail:
* the Swift side's `assumeIsolated` is a precondition on exactly this, so changing the
* dispatcher here turns a working app into a trap.
*/
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/**
* Callbacks are invoked on the MAIN THREAD, always — synchronously when the caller is
* already there. Consumers may assume main-actor isolation.
*/
fun observe(onState: (FeedState) -> Unit): Cancellable { … }
/**
* Callbacks are invoked on an ARBITRARY thread. Named differently on purpose: the two
* guarantees cannot share a function name, because the Swift call site has to choose a
* different adapter and the type system will not tell it which.
*/
fun observeOffMain(onState: (FeedState) -> Unit): Cancellable {
// The controller's own scope, not a fresh one per call. A CoroutineScope constructed
// inside the function is a Job nobody owns — cancelling the returned handle stops the
// collection, and a caller who drops the handle leaks the collector instead.
val job = offMainScope.launch { state.collect { onState(it) } }
return Cancellable { job.cancel() }
}
private val offMainScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Cancels both scopes. Called from the platform when the controller is discarded. */
fun close() { scope.cancel(); offMainScope.cancel() }
}
// iosApp/Sources/KotlinBridge.swift
//
// The one place the two systems meet. Centralised so the unsafe-looking call appears once,
// with the invariant it depends on written next to it, rather than at every call site.
enum KotlinBridge {
/// For callbacks the Kotlin contract guarantees are on main.
///
/// `assumeIsolated` is a PRECONDITION, not a silencer: it compiles to an assertion that
/// the current context is the main actor and traps if not. So a Kotlin-side dispatcher
/// change is a loud crash in the first second of use rather than silent corruption —
/// which is the property `@unchecked Sendable` would have thrown away.
static func onMain<T>(_ value: T, _ body: @MainActor (T) -> Void) {
MainActor.assumeIsolated { body(value) }
}
/// For callbacks with no thread guarantee. Costs one main-actor turn, which is the honest
/// price of not knowing.
static func hopToMain<T: Sendable>(_ value: T, _ body: @escaping @MainActor (T) -> Void) {
Task { @MainActor in body(value) }
}
}
// iosApp/Sources/FeedModel.swift
@MainActor
final class FeedModel: ObservableObject {
@Published var state: FeedState = .loading
private var subscription: Cancellable?
init(controller: FeedController) {
// observe() guarantees main, so assumeIsolated — no hop, no frame of latency, and a
// trap if that guarantee ever stops holding.
subscription = controller.observe { [weak self] newState in
KotlinBridge.onMain(newState) { s in self?.state = s }
}
}
}
And the output it produced:
$ # target build settings: Swift 6 language mode, strict concurrency "complete"
$ xcodebuild -scheme iosApp -configuration Release build
BUILD SUCCEEDED
0 errors, 0 warnings
$ ./tools/update-latency-probe.sh --updates 600
frames to visible coalesced updates
direct (pre-Swift-6) 0 0
Task { @MainActor in } 1.4 avg 38 of 600
assumeIsolated 0 0
How we proved it
That the callback really does arrive on the main thread — because assumeIsolated is a
precondition and shipping it on an unverified assumption is how you get a trap in production:
// a probe, in a debug build, on every callback for one session
controller.observe { _ in
assert(Thread.isMainThread, "callback off main")
probeCounter.record(thread: Thread.current.description)
}
callbacks observed 37,944
on the main thread 37,944
on any other thread 0
sessions probed 37
Thirty-eight thousand callbacks, all on main. Which justifies the precondition and does not prove it — see the limit below.
That Main.immediate is what makes it synchronous, and that the distinction is observable:
dispatcher callback arrives
Dispatchers.Main.immediate synchronously, in the caller's frame (when already on main)
Dispatchers.Main posted, next main-queue turn
Dispatchers.Default on a worker thread
So the Swift side's choice of adapter depends on a Kotlin-side dispatcher the Swift side cannot see — which is why the two Kotlin functions have different names rather than a parameter.
That the Task hop costs a frame and coalesces. Measured, because "just hop, it is safer" is
the easy answer:
600 state updates during a scroll
frames to visible (avg) updates SwiftUI coalesced
assumeIsolated 0.0 0
Task { @MainActor in } 1.4 38
Thirty-eight of six hundred updates never rendered as distinct values. On a progress indicator that is visible as skipping.
That @unchecked Sendable removes a real check. The fixture is the misuse it would then permit:
// with @unchecked Sendable on FeedModel
Task.detached { model.state = .loaded([]) } // compiles, writes from a background thread
@unchecked Sendable compiles, data race, no diagnostic
assumeIsolated at the edge compiles; the detached write is still an ERROR
assumeIsolated narrows the exemption to one boundary; @unchecked Sendable exempts the whole
type everywhere, including the places the checker would have been right.
That assumeIsolated traps rather than corrupting when the invariant breaks. Which is the whole
argument for it over the alternatives, so it is worth demonstrating:
$ # the Kotlin side deliberately switched to Dispatchers.Default
$ # run the app, open the feed
Fatal error: Incorrect actor executor assumption; Expected 'MainActor' executor.
KotlinBridge.swift:14
Immediate, loud, at the boundary, naming the file. Against @unchecked Sendable, where the same
change produces an intermittently-corrupted @Published value and a SwiftUI crash somewhere else.
What I cannot tell you is that 37,944 callbacks is a proof. It is evidence for the paths we exercised across 37 sessions, and a code path that dispatches differently — an error callback, a background refresh added later — would violate the precondition and trap. That is the designed failure mode and it is still a crash in front of a user. The mitigation is that the contract is documented on the Kotlin function and the trap names the one file to look at.
And an honest limit on the two-function approach. observe and observeOffMain encode the
guarantee in a name, which the compiler does not check — nothing stops a future edit from
changing observe's dispatcher while keeping the name. A detekt rule now forbids changing the
scope in that class without updating the KDoc, which is a weak check for a strong requirement, and
I have not found a better one.

What this generalizes to
Two systems can both be correct about the same fact and unable to exchange it, because one encodes it statically and the other establishes it at runtime — and a boundary between them is where an unprovable-but-true invariant has to be asserted or paid for. The useful distinction is between a silencer and a precondition: one removes the check and the other relocates it to a single place where a violation is loud. Prefer the loud one, centralise it, and write the invariant it depends on next to it — because the thing that will eventually break it is a change on the other side of the boundary, made by someone who cannot see your assertion.
Related
- A suspend function reaches Swift as a completion handler — the same boundary, where what does not cross is cancellation.
- A Flow does not cross to Swift. A callback does. — the handle that makes this callback stoppable.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".