Kotlin/Native’s Default Incremental Compilation Isn’t New — It’s Just Your Problem Now

This isn’t a new feature. Kotlin/Native has had incremental compilation of klib artifacts since version 1.9.20, back in late 2023, sitting behind an opt-in flag that most teams never touched. What changed in 2.4.20-Beta1 is narrower and more consequential: that flag is now on by default. If you never explicitly enabled it, you’re about to start using it anyway, on whatever KMP project you upgrade next — including the clean-build cost JetBrains quietly notes in one sentence of the release page, without saying which projects actually pay it.

What “incremental” actually means here, mechanically

Kotlin/Native compilation to a debug binary happens in two distinct stages, and understanding the split is the whole key to this feature. First, your Kotlin source files compile down into klib artifacts — Kotlin’s intermediate representation, not yet native machine code. Second, those klib artifacts, along with every klib your dependencies produce, get compiled and linked into an actual native binary for your target (arm64, x64, whatever you’re building for).

Before 1.9.20, only the second stage had any caching at all, and only for dependency klibs — libraries pulled from Maven that don’t change between your builds. Your own project’s source-to-klib stage was always fully recompiled on every single change, no matter how small. What 1.9.20 added, and what 2.4.20-Beta1 now defaults to, is per-file caching for that first stage specifically: change one file in a 200-file module, and only that file’s contribution to the klib gets recompiled, not the whole module.

The stage that never got faster, and why it matters more than you’d think

Here’s the part the changelog entry doesn’t spell out, and the part worth actually knowing before you decide this feature changes anything for you: incremental compilation only touches the first stage. The second stage — linking klibs into a native binary — gets no benefit from this change at all, and it’s frequently the larger chunk of total build time on non-trivial projects. Developers profiling real Kotlin/Native builds have found the link step alone taking longer than the entire compile-to-klib step, and that number doesn’t move regardless of how small your source change was.

What this means practically: incremental compilation shrinks the part of your build that scales with how much source code changed, but does nothing for the part that scales with your project’s total dependency graph. A one-line fix in a small file gets a genuinely faster compile stage now — but if your linking stage was already your bottleneck, you’ll upgrade, feel almost nothing change, and reasonably wonder what the feature was even for.

Where the clean-build cost actually comes from

The “performance cost for clean builds” JetBrains mentions isn’t a mysterious side effect — it follows directly from what incremental compilation has to do on every invocation, cache or no cache. Before the compiler can reuse anything, it has to check whether reuse is even possible: track which files changed, validate the existing per-file cache state, and decide whether an incremental pass is safe. The compiler’s own internals explicitly model a `RequiresRebuild` outcome for exactly this situation — a clean build has no prior cache to diff against, so all of that bookkeeping work still runs, finds nothing usable, and falls back to a full recompile anyway. You pay the incremental machinery’s overhead and get none of its benefit, precisely on the one kind of build — a fresh checkout, a fresh CI runner — where there was never anything to reuse in the first place.

Deep Dive
Kotlin Null Safety

Why Kotlin Null Safety Shapes Real-World Business Logic Many developers view nullability as a mere tool for avoiding crashes, but Kotlin Null Safety actually drives architectural decisions from the system's edge to the domain layer....

This is exactly why the tradeoff splits so cleanly along one line: repeated local debug builds on a developer’s machine, where the previous build’s cache is still sitting on disk, are the target case this feature was built for. Clean builds — first checkout of the day, a fresh Docker layer, a CI runner that starts from nothing every run — get the bookkeeping cost with nothing to show for it.

The place this quietly costs you: CI/CD pipelines

Most CI/CD setups for Kotlin Multiplatform projects do exactly what incremental compilation is worst at: a clean checkout, no persisted .konan or build cache directory, every run starting from zero. Before 2.4.20-Beta1, that was fine — the feature was opt-in, and nobody was paying the incremental bookkeeping tax on a machine that never benefits from it. After the upgrade, every CI run pays it by default, unless someone explicitly turns it back off.

The fix is one line in gradle.properties, and it’s worth setting deliberately rather than discovering the regression first:

# Disable Kotlin/Native's klib incremental compilation
# — set this specifically for CI/CD runners that don't
# persist build state between runs
kotlin.incremental.native=false

Locally, on a developer’s machine iterating on an iOS target — the exact rebuild-after-rebuild pain this feature targets — leave it on. The distinction isn’t “enable it or don’t,” it’s “match the setting to whether this specific build environment ever gets to reuse a cache.”

Why “default now” is a different claim than “new now”

When 1.9.20 introduced this in 2023, JetBrains labeled it explicitly experimental and asked people to enable it “for evaluation purposes” — language that signals a feature still finding its edge cases, not one ready for silent adoption. Two years and several releases of real-world bug reports later, defaulting it on is JetBrains’ way of saying the failure modes are understood and rare enough to accept broadly. That’s a meaningfully different claim than “here’s something new to try,” and it’s worth reading the default flip that way: not as a new risk being introduced, but as an existing, previously-optional risk being redistributed onto everyone who never opted in to evaluate it themselves.

That redistribution is exactly why teams who skipped the opt-in flag for two years are the ones most likely to be surprised — not because the feature got worse, but because they never went through the “does this work for my project’s module structure” evaluation that early adopters did back in 2023 and 2024.

Technical Reference
Kotlin Testing

Contract Testing in Kotlin: Why Your APIs Break in Production (and How to Fix It) Frontend deploys. Backend deploys. Someone's Swagger was three sprints out of date. Now there's a 500 in prod, a hotfix...

Checking whether it’s actually helping your project

You don’t have to take the “drastically reduces debug compile time” claim on faith — it’s directly observable. Time a same-file, single-line-change debug build twice: once fresh after a full clean, once as a follow-up build with no other changes in between. The gap between those two numbers is roughly what incremental compilation is buying you on your specific module structure. Projects with many small, well-separated source files tend to see the largest gap, because per-file caching has more granularity to work with; projects concentrated in a few very large files see proportionally less, since a change anywhere in a large file still invalidates that file’s whole cached unit.

What this doesn’t touch

Release builds and the linking stage specifically see no change from this feature at all — if your build time problem is a 15-minute link step, this update won’t move that number. It also doesn’t affect JVM or Android targets in a KMP project; this is Kotlin/Native specifically, compiling to iOS, macOS, Linux, or Windows native binaries. And if your CI already persists a build cache directory between runs — some setups do, deliberately, to speed up exactly this kind of incremental work — the clean-build penalty doesn’t apply, because the cache isn’t actually clean in that scenario, and disabling the flag would cost you the benefit for no reason.

Quick answers

Do I need to do anything if I’m not upgrading to 2.4.20 yet? No — this only changes behavior starting with 2.4.20-Beta1. Projects on 2.4.0 or earlier are unaffected either way.

Is this the same thing as the Build Tools API changes also shipping in 2.4.20? No, they’re unrelated features that happen to ship in the same beta. Build Tools API is about decoupling Kotlin compilation from Gradle internals; this is specifically about caching between compilation stages for Kotlin/Native.

How do I know if my CI is affected? If your CI configuration doesn’t explicitly persist a Kotlin/Native build cache directory between runs — most default setups don’t — treat every CI run as a clean build and set kotlin.incremental.native=false there specifically.

The Pattern Underneath This One Release

Look at 2.4.20-Beta1 as a whole, not just the klib line, and a second, more interesting default-flip is sitting right next to it: the Build Tools API rollout for JS, Wasm, and metadata targets. JetBrains enables it by default in this beta, then explicitly plans to pull it back to opt-in between Beta2 and the final release, then flip it to default again starting in 2.5.0. That’s not indecision — it’s a deliberately reversible default, scripted in advance, because the team isn’t yet confident enough in BTA’s blast radius on those newer targets to commit without a retreat option. The klib incremental compilation default, by contrast, ships with no such retreat plan. Same release, same “default now” language, two completely different confidence levels underneath it — and the changelog gives you no signal of that difference unless you already know to look for it.

That gap is the actual story. A team that reads “enabled by default” as a single category of announcement will treat both changes identically. A team that reads it as a confidence signal specific to each feature’s field-testing history will correctly bet that the klib change is closer to settled, and the BTA rollout on JS/Wasm is still actively being reshaped underneath them.

Worth Reading
Kotlin 2.4

Mastering Contextual Abstraction with Kotlin 2.4 Stable Parameters I've been waiting for the death of -Xcontext-parameters since the first previews. Not because the feature was bad — it was always promising — but because "experimental"...

What This Changes About Reading Kotlin’s Release Notes

The interesting risk surface in Kotlin right now isn’t concentrated in the language releases — 2.3.0, 2.4.0 — that get blog coverage and conference talks. It’s sitting in the .20 tooling releases, where a flag that’s been sitting quietly opt-in for one to three years gets flipped with a single caveat sentence and no fanfare. Nobody writes a KotlinConf talk about a default changing on an existing flag. That asymmetry between attention and actual behavioral change is worth correcting for directly: the practical move is treating every .20 changelog as a checklist of flags to grep your own gradle.properties against, not a feature announcement to skim for something new to adopt. The features that quietly change your build’s behavior without you asking are exactly the ones that never show up as a headline.

Checklist for Your Team

Grep your gradle.properties at every Kotlin version bump — not just this one. Marketing-style changelog headlines tell you what’s new; they don’t tell you which existing flags just changed their default. The only reliable way to catch a silent default flip before it catches you is checking your own config against the release notes’ full flag list, every time, not just skimming for feature names.

  • Before upgrading to 2.4.20, check whether kotlin.incremental.native is already set in your project. If it’s absent, you’re about to inherit the new default silently.
  • Set it explicitly either way — true or false — rather than leaving it implicit. An explicit value survives the next default flip; an absent one doesn’t.
  • Split the setting by environment if your CI and local dev use different gradle.properties profiles: on for developer machines, off for clean-checkout CI runners.

The blast radius of this specific flag is not uniform across project types, and that’s worth stating plainly rather than implying. On a pure Android project targeting only the JVM, this change is invisible — Kotlin/Native isn’t in the compilation path at all, so there’s nothing to grep for. On a Kotlin Multiplatform project with an iOS, macOS, or other native target, this is the first thing worth checking after any Kotlin version bump, because it’s the default most likely to change your CI build time without a single line of your own code changing. Treat KMP projects and JVM-only projects as needing two different upgrade checklists, not one shared one — the same version bump carries real risk for one and zero risk for the other.

Written by:

Source Category: Kotlin: Hidden Pitfalls