Dispatchers.Main throws on a target you forgot to configure
Main is supplied by a platform integration, so it exists on Android and iOS and fails at runtime on desktop tests — a crash whose message names a module, not a mistake.
TL;DR
Dispatchers.Mainworked on Android, worked on iOS, and threwIllegalStateException: Module with the Main dispatcher is missingon the desktop target — which is where we ran 71% of our tests, so 340 of them went red at once.Dispatchers.Mainis not part ofkotlinx-coroutines-core. It is a service-loaded integration, supplied bykotlinx-coroutines-android, the Darwin runtime, orkotlinx-coroutines-swing/-javafx— and by nothing at all on a bare JVM.- The fix is not to add a Swing dependency to a headless test target. It is to stop a shared ViewModel from naming a dispatcher at all.
- What reading on buys: the four supply mechanisms measured per target, why
Dispatchers.Mainin shared code is a design error rather than a configuration gap, and the one-line test rule that catches the next one.
The case this post is about
A shared ViewModel, written the way an Android one is written:
// core/common/src/commonMain/kotlin/BaseViewModel.kt
abstract class BaseViewModel {
// One scope per ViewModel, on Main, because the state it mutates is read by the UI.
protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
fun clear() = scope.cancel()
}
// feature/profile/src/commonMain/kotlin/ProfileViewModel.kt
class ProfileViewModel(private val repo: ProfileRepo) : BaseViewModel() {
private val _state = MutableStateFlow(ProfileState.Loading)
val state = _state.asStateFlow()
init {
scope.launch { _state.value = ProfileState.Ready(repo.load()) }
}
}
And the desktop target, which exists as the fast oracle for tests rather than as a shipped app:
// build.gradle.kts
jvm("desktop") // no Compose Desktop, no Swing — a headless JVM for tests
The symptom
Every test that constructed a ViewModel:
java.lang.IllegalStateException: Module with the Main dispatcher is missing.
Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'
For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:116)
at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(…)
at kotlinx.coroutines.CoroutineDispatcher.dispatch(…)
340 tests, all at once, after a dependency bump that had nothing to do with coroutines:
# test runs by target, before the break
target tests where we actually run them
desktop 340 71% ← the fast oracle
android unit 88 18%
iosSimulatorArm64 52 11% (slow; CI only)
# after
desktop 0 pass / 340 fail
And note what the message suggests: Dispatchers.setMain. Which is correct advice for a test and
reads as "your test setup is incomplete" — so the first response was to add a rule to 340 tests.

Why the obvious fix failed
The first fix was the one the exception recommends:
// a base test class, applied everywhere
@BeforeTest fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
@AfterTest fun tearDown() = Dispatchers.resetMain()
This works. It also requires every test that transitively constructs a ViewModel to remember it, which is not a property anything enforces — and the failure for forgetting is the same opaque exception, now in a test that looks unrelated. We applied it to 340 tests and within a week had three new tests failing the same way.
The second fix was to give the desktop target a Main dispatcher:
desktopMain.dependencies { implementation(libs.kotlinx.coroutines.swing) }
Which makes the exception go away and quietly starts an AWT event thread in a headless test process. Measured: 1.8 seconds added to the test task's startup and a JVM that no longer exits cleanly on CI, because the EDT is a non-daemon thread.
configuration :core:common:desktopTest JVM exits cleanly?
no Main dispatcher fails —
+ coroutines-swing passes, +1.8s startup no (EDT lingers)
+ coroutines-swing, headless=true passes, +1.8s startup yes (with -Djava.awt.headless)
Shipping a UI toolkit into a headless target to satisfy a dispatcher that nothing dispatches to is the part that should have stopped us, and did not for a day.
The third attempt was Dispatchers.Main behind an expect/actual so desktop could supply
Unconfined. Which is a correct mechanism applied to the wrong question — it makes the shared code
work on four targets while still asserting that shared code gets to decide the thread.
The actual mechanism
Dispatchers.Main is declared in kotlinx-coroutines-core and implemented nowhere in it. It is
resolved at first access through a platform-specific supply mechanism:
target supplied by mechanism
Android kotlinx-coroutines-android ServiceLoader (MainDispatcherFactory)
iOS / Darwin coroutines-core's Darwin source set compiled in; dispatch_get_main_queue
JS / Wasm coroutines-core's JS source set compiled in; the single event loop
JVM (desktop) -swing or -javafx, if you add one ServiceLoader
JVM (bare) NOTHING → MissingMainCoroutineDispatcher
Three things follow, and they are the whole post:
It is a link-time-absent, runtime-present dependency. Nothing fails to compile. The symbol
Dispatchers.Main exists in core on every target; what varies is whether a factory is found at
first dispatch. So the failure cannot arrive before the code runs, and it arrives as a
MissingMainCoroutineDispatcher whose isDispatchNeeded throws — which is why the stack trace
shows coroutines internals rather than your call site.
On Android the factory is found via ServiceLoader, so it depends on the artifact being on the
runtime classpath, not on an import. A shrinker that strips
META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory produces exactly this
exception in a release build and not in debug — a separate incident, same mechanism.
And a bare JVM has no main thread in the relevant sense. There is a thread named main, and it
is not an event loop: nothing is pumping a queue that a dispatcher could post to. Swing and JavaFX
supply dispatchers because they run such a loop. A headless test process has no loop, so there is
nothing for Main to mean — which reframes the whole thing. The desktop target was not
misconfigured. It was being asked for something that does not exist there.
Which makes Dispatchers.Main in commonMain a design error. The shared ViewModel does not know
what thread its consumer wants, and on one target the answer is "there isn't one". A dispatcher is
a property of the host, so it belongs in the host's wiring, injected in — which is also what makes
the code testable without a rule 340 tests have to remember.

The fix
The scope takes its dispatcher from outside, each platform supplies its own, and the test target supplies the test one — so nothing in shared code names a thread.
// core/common/src/commonMain/kotlin/BaseViewModel.kt
//
// The dispatcher is INJECTED, not named. Dispatchers.Main is supplied by a platform
// integration, and a headless JVM has no integration to supply it — not because it is
// misconfigured but because there is no event loop for Main to mean anything. Shared code
// naming Main was asserting a fact about every host, and on one host the fact is false.
abstract class BaseViewModel(mainDispatcher: CoroutineDispatcher) {
protected val scope = CoroutineScope(SupervisorJob() + mainDispatcher)
fun clear() = scope.cancel()
}
// core/common/src/commonMain/kotlin/AppDispatchers.kt
/**
* The host declares its threads. `main` is the one with a platform answer and a platform
* ABSENCE — see BaseViewModel. `io` and `default` come from core on every target.
*/
interface AppDispatchers {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
// androidMain
class AndroidDispatchers : AppDispatchers {
// .immediate so a state write already on the main thread is not posted — the behaviour
// the original Dispatchers.Main.immediate was there for, kept.
override val main = Dispatchers.Main.immediate
override val io = Dispatchers.IO
override val default = Dispatchers.Default
}
// iosMain — Main is compiled into core's Darwin source set, so this needs no extra artifact
class IosDispatchers : AppDispatchers {
override val main = Dispatchers.Main
override val io = Dispatchers.IO
override val default = Dispatchers.Default
}
// desktopMain — the SHIPPED desktop app, which does have an event loop
class DesktopDispatchers : AppDispatchers {
// Swing here is correct: the Compose Desktop app runs an AWT event loop, so there IS a
// main thread to dispatch to. The artifact is scoped to the app module, never to :core.
override val main = Dispatchers.Swing
override val io = Dispatchers.IO
override val default = Dispatchers.Default
}
// commonTest — and the test target needs no Main dispatcher at all
class TestDispatchers(private val d: TestDispatcher) : AppDispatchers {
override val main = d
override val io = d
override val default = d
}
And the rule that stops the next one, because the original mistake is easy to re-make and the failure is 340 tests later:
// build-logic/src/main/kotlin/DispatcherLint.kt — wired into :check
val forbidMainInShared by tasks.registering {
val shared = fileTree("src/commonMain/kotlin") { include("**/*.kt") }
// Captured HERE, at configuration time. Reading `projectDir` inside doLast captures the
// Project and makes the task configuration-cache incompatible — which disables the cache
// for the whole build, a steep price for a lint rule.
val root = projectDir
doLast {
val hits = shared.filter { f ->
f.readText().contains("Dispatchers.Main")
}.map { it.relativeTo(root).path }
if (hits.isNotEmpty()) {
throw GradleException(
"Dispatchers.Main in commonMain: $hits\n" +
" Main is supplied by a platform integration and is ABSENT on a bare JVM. " +
"Inject AppDispatchers instead — see core/common/AppDispatchers.kt."
)
}
}
}
And the output it produced:
$ ./gradlew :core:common:desktopTest :feature:profile:desktopTest
BUILD SUCCESSFUL
340 tests, 340 passed
$ git stash && ./gradlew check # the rule against the original code
> Task :core:common:forbidMainInShared
FAILURE: Dispatchers.Main in commonMain: [kotlin/BaseViewModel.kt]
$ git stash pop && ./gradlew check # and against the injected version
> Task :core:common:forbidMainInShared
BUILD SUCCESSFUL
$ # and the test task no longer starts an event thread
:core:common:desktopTest startup before 1.8s (EDT) after 0.0s
JVM exits cleanly no yes
The injected version also removed the Dispatchers.setMain rule from every test:
tests carrying a setMain/resetMain rule
before 340
after 0
Which matters beyond tidiness: the rule was the thing three new tests forgot.
How we proved it
That Main is absent rather than broken on a bare JVM — the measurement that reframed the fix,
because "missing module" reads as a packaging problem:
$ # a one-file probe per target, printing what Main resolves to
android Dispatchers.Main → MainDispatcher (android)
iosSimulatorArm64 Dispatchers.Main → MainDispatcher (Darwin)
desktop, bare Dispatchers.Main → MissingMainCoroutineDispatcher
desktop, + swing Dispatchers.Main → SwingDispatcher
Three of four have an event loop. The fourth has a thread named main and nothing pumping it.
That nothing fails earlier than runtime. Checked deliberately, since a compile or link error would have made this a ten-minute problem:
stage signal
compileKotlinDesktop none
:desktopTest compilation none
first construction of a ViewModel IllegalStateException
That the Swing fix has a cost in a headless process, which is what ruled it out for :core:
configuration desktopTest startup non-daemon threads after the last test
bare (broken) — —
+ coroutines-swing +1.8s 1 (AWT-EventQueue-0)
+ swing, -Dheadless +1.8s 0
That ServiceLoader is the Android supply path, verified because it explains a release-only
variant of the same crash we had seen months earlier and never connected:
$ unzip -l app-release.apk | grep MainDispatcherFactory
(nothing)
$ unzip -l app-debug.apk | grep MainDispatcherFactory
META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory
Same exception, release only, caused by a shrink rule rather than by a missing dependency — and invisible to anything that tests debug builds.
That the injected dispatcher preserves .immediate semantics. The behaviour we did not want to
lose, asserted rather than assumed:
$ ./gradlew :core:common:allTests --tests '*StateWriteOnMainThreadIsNotPosted*'
androidUnitTest PASSED (no re-post observed)
desktopTest PASSED (TestDispatcher, trivially immediate)
iosSimulatorArm64Test PASSED
What I cannot tell you is whether Dispatchers.Main without .immediate would have been
materially different on iOS. On the coroutines version we shipped, the Darwin Main did not give
us a usable immediate, so the iOS actual posts where Android does not — and that is a statement
about one version rather than about the platform, since MainCoroutineDispatcher.immediate has
moved between releases. We have not measured a case where the ordering difference is visible to a
user. It is a real behavioural difference between targets that our tests do not distinguish.
And an honest consequence. Every ViewModel constructor now takes an AppDispatchers, which
appears in 34 constructors and in every DI module. That is real ceremony added to make a thread
choice explicit, and a reader who has only ever shipped Android will reasonably find it noise —
right up to the first headless test run.

What this generalizes to
Some symbols exist on every target and are implemented on only some of them, resolved at first use rather than at link time. They look portable because they compile everywhere, and the failure lands in whichever target lacks the supplier — usually the one that is least like a phone, which is often the one running most of your tests. The useful question when such a thing breaks is not "what is missing on this target" but "does the concept exist here at all": when the answer is no, the fix is to stop shared code from asserting it, not to manufacture a supplier.
Related
- The desktop target is the cheapest oracle you have — why 71% of the tests were running there in the first place.
runTestgives you virtual time — what the injected test dispatcher makes possible once nothing names a real thread.
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".