· 10 min read Posted by Nacho Carrión
Runtime crashes hiding in your Gradle files
Gradle dependency drift in KMP projects, and why the crash shows up on iOS
There is a particularly tricky class of bug which we’ve seen teams encounter in their KMP projects. A dependency version changes somewhere in the graph. Every build is green. The release goes out, and crashes start arriving from a feature nobody touched, usually on iOS, usually with an error most of the team has never seen before.
It isn’t caused by bad code. It’s caused by the gap between the version a piece of code was compiled against and the version that actually ends up in the binary. That gap opens most easily when a project is spread across repositories, with each KMP feature living in its own repo and publishing artifacts for an app to assemble. But the same gap can open inside a single repository whenever modules declare their own dependency versions instead of sharing one source of truth.
The actual library change which triggers it doesn’t actually matter. What does is the resolution mechanisms which allow it to happen.
What it looks like
Here’s an example you may have encountered in your own project recently. Two feature modules. One is still on kotlinx-datetime 0.6.2, the other has been bumped to 0.7.0.
// feature-auth
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
// feature-orders
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.0")
Here’s why that particular pair is a problem. Before 0.7.0, kotlinx-datetime provided its own kotlinx.datetime.Instant and kotlinx.datetime.Clock. The Kotlin standard library then added identical kotlin.time.Instant and kotlin.time.Clock types, since Instant turned out to be useful well outside datetime code, and 0.7.0 removed the kotlinx-datetime versions in favour of the stdlib ones. Any module compiled against 0.6.x that uses kotlinx.datetime.Instant now references a class that doesn’t exist in 0.7.0. The library is trusted, it’s in nearly every project, and the change caught a lot of teams off guard, which is why it’s been the most frequent trigger we’ve seen lately.
feature-auth uses kotlinx.datetime.Instant in its session model. The app depends on both features.
Everything compiles. Everything publishes. The app assembles, launches, and dies the first time someone opens a screen backed by feature-auth, with a missing-class error for kotlinx.datetime.Instant.
Whether those two modules live in two repositories or one, the result is the same.
Why the build doesn’t catch it
Gradle’s default conflict resolution is newest wins. When it finds two requests for the same library, it picks the highest version, and everything on that classpath gets it. That’s the only sane default, since you can’t have two versions of the same class loaded at once. But it’s silent. There’s no output saying “this module was built against 0.6.2 and is getting 0.7.0.”
In a multi-repo setup, the problem is obvious once you see it. feature-auth was compiled in its own repo, against its own declared version, and published as an artifact. Nothing recompiles it when the app resolves 0.7.0. Its bytecode is already written, and it references a class that the app’s resolved version doesn’t have.
What’s less obvious is that a monorepo doesn’t save you. We tested this: two modules in the same repo, one hardcoded to 0.6.2 and one to 0.7.0, and it crashes exactly the same way. The reason is that Gradle resolves each configuration on its own. feature-auth’s compileClasspath contains feature-auth’s declared dependencies and nothing else, so it compiles against 0.6.2. Only the app’s runtimeClasspath sees both modules and both requests, and that’s where newest-wins kicks in. By then the compiled output is already sitting there with a dangling reference.
So the compile step and the runtime step are both individually correct, and they disagree about which version of the library exists. Being in the same repository does nothing to close that gap. Sharing the version declaration does.
On iOS it’s worse
The same conflict on Android gives you a stack trace with the missing class named in it. Annoying, but you’re an hour from a fix. On iOS a second assumption fails on top of the first.
The KMP version of the scenario: feature-auth’s Kotlin/Native compilation resolves its own klib dependencies, gets 0.6.2, and produces a klib that references kotlinx.datetime/Instant. The final framework links feature-auth, feature-orders, and the resolved 0.7.0 together. The reference dangles.
You’d expect a link error. Kotlin/Native links ahead of time, so a missing declaration ought to stop the build. If you learned Kotlin/Native a few years ago, that’s exactly what happened.
When Kotlin/Native compiles a module it does not produce machine code; it produces a klib, which contains the module’s serialized intermediate representation and metadata. A klib does not embed its dependencies but references them by signature, so feature-auth’s klib records that it uses a class called kotlinx.datetime/Instant, based on the 0.6.2 version that was on its compile classpath, and it builds without error. Machine code is only generated when the final framework is compiled. At that point the compiler collects every klib in the graph, deserializes their IR and resolves the references between them before handing the result to LLVM. Since Gradle can only provide one kotlinx-datetime klib for that step, and newest wins selects 0.7.0, the references in feature-auth to kotlinx.datetime/Instant have nothing to resolve against. Under full linkage this would have failed the build. Under partial linkage, which has been the default since Kotlin 1.9.0, the compiler instead replaces each unresolved usage with a stub that throws IrLinkageError when executed and continues compiling the rest of the IR normally. LLVM receives valid code, the native linker finds no missing symbols, and the framework is produced and embedded as usual. The error only appears at runtime, when the app first executes a code path that reaches one of those stubs.
The linker used to catch this
The old behaviour was full linkage: every symbol had to resolve or compilation failed. Partial linkage replaced it. When a klib references a declaration that no longer exists in a dependency, the compiler doesn’t fail. It replaces the broken reference with a stub that throws if it’s ever actually called, and lets the build finish. Hit that code path on a device and you get an IrLinkageError.
The reasoning is sound. Libraries drop APIs you never call, and failing an entire build over a symbol nothing reaches is a bad trade. What matters for this post is how the default evolved, because it got quieter over time, not louder.
| Version | Change |
|---|---|
| 1.9.0 | Partial linkage on by default for Kotlin/Native and Kotlin/JS. The Native compiler reports a warning every time it detects a linkage issue. |
| 1.9.20 | Default log level becomes silent. Issues are still detected but no longer reported unless you opt in with -Xpartial-linkage-loglevel. Error messages switch from hashes to readable signature names. |
| 2.0.0 | Kotlin/Wasm follows. |
| 2.4.0 | Partial linkage is always on. -Xpartial-linkage is deprecated, so the off switch is gone. The default log level stays silent across all Kotlin compilers. |
The 1.9.20 row is the one that matters day to day. On any current Kotlin version, a dangling reference in your iOS build produces no output. Not a warning you scrolled past. Nothing. The build is clean, the framework assembles, the app launches, and the failure waits on whichever screen touches the missing symbol.
That’s what separates this from most build problems. Usually the information was there and nobody read it. Here it was never printed. And since 2.4.0 you can’t switch the mechanism off. You can only ask it to speak up, which as it turns out is the right move anyway.
Why the iOS engineers get the worst of it
IrLinkageError doesn’t behave like an iOS crash. It doesn’t come from anyone’s Swift. The symbol it names lives in a shared module. And the actual cause is a version conflict in a build system the iOS engineers rarely interact with.
It doesn’t mean iOS team lacks knowledge on the build tool. The failure surfaces on the platform furthest from where it’s caused, and the compiler that could have flagged it was configured to say nothing.
This isn’t about datetime
Any library can be the trigger. It only needs to be used widely enough that two parts of your graph can disagree, and to have changed something binary-incompatible. Kotlin makes the second condition easy to hit without touching a public signature:
- Default arguments compile to a synthetic
$defaultoverload with a bitmask parameter. Insert a parameter in the middle and the signature changes, even though every call site still compiles. - Inline functions are copied into the caller. If v1’s inline body called an internal helper that v2 deleted, the compiled caller references a symbol that’s gone.
const valis inlined at the call site. Change the value and old callers keep the old one forever. No crash, just wrong behaviour.data class copy()changes signature when a property is added.- Sealed hierarchies and enums gain a subclass, and previously exhaustive
whenexpressions fall through to a synthetic throw. - Value classes get name-mangled with a hash suffix, which is about as fragile as Kotlin ABI gets.
Source compatibility and binary compatibility are different guarantees, and Gradle’s resolution only ever looks at the version number.
What to set up
A version catalog, and nothing declared outside it
One libs.versions.toml, every version in it, and a review rule that inline version strings in module build files don’t get merged. This is the direct fix for the example above: both modules resolve the same coordinate to the same version, so there’s never a conflict to resolve.
Across repositories, publish the catalog as an artifact so every repo consumes the same one:
// settings.gradle.kts
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
from("com.example:version-catalog:2026.8.1")
}
}
}
This is why we recommend monorepos and catalogs, and why the catalog is doing more of the work than people assume. A monorepo makes a catalog easy to adopt and easy to enforce in review. It doesn’t substitute for one.
Turn partial linkage up
The highest-value change for iOS, and almost nobody has it on. Since the default is silent, this isn’t about paying attention to warnings you’ve been ignoring. It’s about getting the compiler to produce them at all.
kotlin {
targets.withType<KotlinNativeTarget>().configureEach {
compilations.configureEach {
compilerOptions.configure {
freeCompilerArgs.add("-Xpartial-linkage-loglevel=ERROR")
}
}
}
}
ERROR fails the build on a linkage issue. WARNING logs it without failing. Check the flag against your Kotlin version, since the semantics of -Xpartial-linkage-loglevel were revised in 2.4.
Go straight to ERROR if you can. The objection is false positives: you’d be failing builds over symbols nothing calls, which is exactly the case partial linkage exists to allow. That cost is real, though 2.4 reduced it by no longer reporting cases that can’t lead to a runtime error. Take the trade anyway. An IrLinkageError caught in CI costs an hour. The same error in a shipped iOS build costs a release cycle, plus however long it takes someone on the iOS side to work out that the problem lives in a Gradle file.
An integration test that runs on a simulator
Unit tests with mocked boundaries will never catch any of this. You need something that runs real code across module seams, on every target, in CI. It doesn’t have to be exhaustive. It does have to run on an iOS simulator, because a passing JVM test suite tells you nothing about whether the Native binary links cleanly.
The lesson
Gradle guarantees a consistent dependency graph per configuration, not a correct one across your project. The version a module compiled against is decided in one place, the version it runs against in another, and nothing in between checks that they agree. That’s true across repositories and, less obviously, inside one.
On Native there’s a second stale assumption stacked on the first. The linker used to catch this. It hasn’t since 1.9.0, it’s said nothing about it since 1.9.20, and since 2.4.0 you can’t turn the behaviour off, only ask it to speak up.
If you’re not sure how your modules resolve dependencies, go check. Centralize your version declarations. Make the Kotlin build fails before anything reaches the iOS side.