The desktop target is the cheapest bug oracle you are not using
It builds in seconds, runs real Compose, and needs no device.
TL;DR
Most Compose Multiplatform projects target Android and iOS and skip desktop, because nobody is shipping a desktop app. That reasoning is about the product; the desktop target's value is as a development tool.
It compiles in seconds instead of minutes, runs real Compose Multiplatform with real layout and
real recomposition, needs no simulator or device, and runs commonTest on the JVM where every
profiler and debugger already works.
Adding it to an existing project surfaced three live bugs in shared code the same afternoon — not because desktop is special, but because it made the feedback loop short enough to actually look.
The case this post is about
A shared module targeting Android and two iOS targets. Normal, and the iteration loop was slow enough that nobody measured it:
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
// no jvm() — "we don't ship a desktop app"
}
Changing one line in a shared composable meant either an Android build and install, or a Kotlin/Native compile plus a framework link plus an Xcode build plus a simulator boot. Both are minutes. The team had adapted by batching changes, which is the expensive adaptation.
The symptom
Not a crash — a cost, which is why it goes unexamined for so long. Measured over one afternoon:
one-line change in a shared composable, time to see it rendered
Android (device attached) 1m 48s
iOS simulator (warm) 4m 12s
iOS simulator (cold boot) 7m 30s
And the consequences of a four-minute loop, which are the actual symptom:
Changes get batched. Six edits, one build. When something breaks you have six candidates.
Experiments do not happen. "Does this layout work with a longer string?" costs four minutes, so you guess instead of checking.
commonTest ran on androidUnitTest only, because it was the fast one — and that is the
single-target-coverage problem that lets platform-specific bugs through.
And debugging shared code was awkward. Breakpoints in shared Kotlin from Xcode are workable
and worse than a JVM debugger; println was the common tool, with all the problems that has.
Why the obvious fix failed
"Use the Android target as the fast loop." Better than iOS and still a build-install cycle, and it tests Compose's Android renderer. For a layout bug that reproduces on both, that is fine; for anything involving Compose Multiplatform's own rendering, it is the wrong oracle.
"Use Compose previews." They were already in use and they do not run logic — no ViewModel, no coroutines, no state machine. They answer "how does this look", not "does this work".
"Add Compose Hot Reload." Genuinely useful and not a substitute: it shortens the UI iteration loop without giving you a JVM target for tests, a profiler, or a screenshot host.
"We'd have to maintain a desktop app." The assumption that blocked it for months, and it is
false — adding jvm() to the shared module does not require shipping an application. Even the tiny
dev harness below is optional; the test and screenshot value arrives with the target alone.
The actual mechanism
Three properties, and none of them is about desktop being a good product platform.
JVM compilation is fast and cacheable. No native compile, no link step, no framework. It is the same reason the JVM target is the one that gets served from the build cache while native targets pay full price.
Compose Multiplatform on desktop is real Compose. Same compiler plugin, same runtime, same
layout and recomposition, rendered through Skia — which is what iOS uses too. It is a much closer
oracle to Compose-on-iOS than Compose-on-Android is, because Android has its own
View-interop-shaped rendering history.
The JVM has the mature tooling. A real debugger with working breakpoints and expression evaluation, async stack traces, a heap profiler, a flight recorder. None of that is unavailable elsewhere; all of it is better here.

The value of a target is not only whether you ship it. A target is a place to run your shared code, and the cheapest place to run it is where you will actually run it often.
The fix
Add the target, point tests at it, and keep a tiny harness for visual iteration.
// build.gradle.kts
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
jvm("desktop") // development oracle, not a shipped product
sourceSets {
val desktopMain by getting
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
}
val desktopTest by getting
desktopTest.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlin.test)
}
}
}
That is the whole required change. Two optional additions make it pay off daily.
A dev harness — about twenty lines, for rendering one screen in isolation:
// desktopMain/kotlin/DevHarness.kt
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "dev harness") {
AppTheme {
// swap the subject under test; no DI, no navigation, no app shell
ReadingListContent(
state = ReadingSamples.ready,
onItemClick = {},
onRefresh = {},
)
}
}
}
$ ./gradlew :shared:runDesktop # 11s cold, ~3s warm
And commonTest running on desktop as the default local loop:
$ ./gradlew :shared:desktopTest # the fast one, locally
$ ./gradlew allTests # every target, in CI
The ordering matters: desktop for the iteration loop, every target in CI. Using desktop instead of the matrix would reproduce the single-target-coverage problem with a different single target.
That last sentence is the one worth enforcing mechanically, because it decays silently: desktop is the pleasant target, so it quietly becomes the only one anyone runs, and nothing complains until a platform bug reaches a user. The check is small — read which test tasks actually ran, and refuse a CI build that covered only the cheap one:
// build-logic/.../OracleCoverageTask.kt — registered as a dependency of `check` in CI
abstract class OracleCoverageTask : DefaultTask() {
@get:InputFiles abstract val testResults: ConfigurableFileCollection
@get:Input abstract val isCi: Property<Boolean>
/** Every target whose absence we consider a coverage hole, not a preference. */
private val required = setOf("androidUnitTest", "iosSimulatorArm64Test", "desktopTest")
@TaskAction
fun verify() {
val ran = mutableSetOf<String>()
for (dir in testResults.files) { // one dir per target that executed
if (!dir.isDirectory) continue // target never ran — nothing to read
val target = dir.parentFile.name
val passed = dir.listFiles { f -> f.name.endsWith(".xml") }
?.any { "<testsuite" in it.readText() } == true
if (passed) ran += target
}
val missing = required - ran
when {
missing.isEmpty() -> logger.lifecycle("oracle coverage: ${ran.sorted()} ✓")
// Locally, desktop-only is the POINT of this post. Say so and allow it.
!isCi.get() -> logger.lifecycle(
"oracle coverage: ran ${ran.sorted()}, skipped $missing — fine locally, CI runs all",
)
// In CI it is the monoculture this post warns about. Refuse.
else -> throw GradleException(
"oracle coverage: only ${ran.sorted()} ran; $missing never executed. " +
"Desktop is the fast oracle, not the only one — run `./gradlew allTests`.",
)
}
}
}
On the named case — the afternoon the target was added, with CI still pointing at the old
desktopTest-only invocation it had been given as a speed-up:
$ CI=true ./gradlew check
> Task :shared:oracleCoverage FAILED
oracle coverage: only [desktopTest] ran; [androidUnitTest, iosSimulatorArm64Test]
never executed. Desktop is the fast oracle, not the only one — run `./gradlew allTests`.
$ ./gradlew check # same task, local
oracle coverage: ran [desktopTest], skipped [androidUnitTest, iosSimulatorArm64Test]
— fine locally, CI runs all
The branch is the whole design: the same condition that is correct on a laptop is a defect in CI, so the check reads where it is running rather than applying one rule to both.
How we proved it
The loop time, measured rather than asserted:
one-line change in a shared composable, to rendered
iOS simulator (warm) 4m 12s
Android (device) 1m 48s
desktop harness (warm) 3s
desktop harness (cold) 11s
Three bugs found the same afternoon. This is the real evidence, and it is worth being specific about what each one was, because "shorter loop finds bugs" is otherwise just a slogan.
A long-title layout break. Checking a 90-character title took three seconds instead of four minutes, so it got checked:
ReadingRow with 90-char title → title overlaps the trailing timestamp
Present on Android and iOS too. It had shipped because nobody had rendered a long title.
An infinite recomposition loop. Visible instantly on desktop as a pegged CPU and a frame counter that would not settle:
// the bug — a new lambda identity every recomposition, feeding a keyed effect
LaunchedEffect(onRefresh) { … }
Worth noting what made this one findable: the JVM profiler. A Recomposer hot loop is a few
clicks away in a JVM profiler and considerably more work to see from Xcode.
A state machine that could reach an impossible state. Found by stepping a shared ViewModel in a
real debugger — submitting = true with errorMessage set, which the UI had no rendering for.
And the coverage check, to confirm desktop had not become a new monoculture:
./gradlew allTests
jvmTest(desktop) PASSED (1.9s)
androidUnitTest PASSED (4.2s)
iosSimulatorArm64Test PASSED (28.1s)
Two limits I want to be clear about. Desktop is a different renderer configuration, not iOS — font metrics, density and input differ, so a layout that passes on desktop can still be wrong on a phone. And none of the three bugs was desktop-specific: every one reproduced on Android and iOS. That is the actual claim of this post. Desktop did not find bugs that other targets could not; it found bugs the other targets' loop length discouraged anyone from looking for.
What this generalizes to
Feedback-loop length changes what you investigate, not just how fast you work. A four-minute check gets skipped and replaced by a guess. A three-second check gets run. Most of the value here is behavioural, and it is invisible in any metric except the bugs you did not find.
Pick your oracle for cost and fidelity separately. The question is not "which platform do we ship" but "where can I run this shared logic most cheaply while still learning something true". Those are different questions and usually have different answers.
Mature tooling is a reason to choose a runtime. The JVM debugger and profiler found two of the three bugs. When a platform's tooling is better, running your portable code there is a legitimate engineering decision, not a shortcut.
A fast loop must not become the only loop. Desktop locally, every target in CI. The moment the
fast oracle becomes the sole oracle, you have swapped one blind spot for another — which is the
same mistake as running commonTest only on androidUnitTest.
Related
- @Preview is Android-only, and your shared UI has no preview at all — the screenshot host this enables
- commonTest runs on every target, which is the point and the cost — why desktop cannot be the only target
- One Gradle build, five outputs, and the cache that only helps three — why JVM targets are the fast ones
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".