Kotlin Multiplatform Is a Dial, Not a Switch
Every other cross-platform framework makes you buy the whole runtime. KMP lets you choose how far up the stack you share, feature by feature. Here's the dial, what each detent costs, and who's actually paying for it.
Every cross-platform framework I used before Kotlin Multiplatform asked the same question at the door: are you in or are you out?
React Native, Flutter, Xamarin, Cordova. Pick one and you’ve bought the whole runtime. You get their rendering model, their threading model, their build system, their debugger, their idea of what a button is. The moment you need something the abstraction didn’t anticipate, you’re writing a bridge, and you’re writing it in the seam between two worlds where nobody wants to be.
I wrote about this in 2020, when I argued that Steve Jobs’ famous complaint about Flash applies to almost every cross-platform toolkit but not to KMP. What I didn’t notice at the time is that my exemption had a condition buried inside it. The reason I gave was that KMP apps ship fully native iOS UIs, which in 2020 was simply true, because Compose Multiplatform on iOS didn’t exist yet. I was making an argument about part of the stack and I thought I was making it about all of it.
Six years later I want to make the argument properly, because the thing that makes KMP different isn’t that it shares more code. Plenty of frameworks share more code.
It’s that KMP is a dial, and everything else is a switch.
The dial
A detent is the click-stop on a dial, the place it wants to rest. KMP has five, and you can stop at any of them.
| Detent | What’s shared | Ecosystem | Reality check |
|---|---|---|---|
| 1 | Domain models, validation, algorithms | Pure Kotlin, zero deps | Boring. Works. |
| 2 | + networking, persistence, DI | Ktor, SQLDelight, Room, DataStore | Where Google stops |
| 3 | + state management | ReduxKotlin and friends | Your call |
| 4 | + UI | Compose Multiplatform | Stable on iOS since May 2025 |
| 5 | + nearly everything, minus the shims | expect/actual, DI | Bitkey ships ~95% |
The rows are cumulative, but they’re not a commitment. You get one dial per feature, not one per app. Your sync engine can sit at 5 while your onboarding flow sits at 1, because you want onboarding to feel exactly right on iOS and you have an engineer who cares deeply about that. Nothing in the toolchain objects.
That’s the whole pitch. The rest of this post is what each detent actually costs.
Detent 1: pure logic
Domain models, business rules, parsing, validation, that one gnarly pricing algorithm that’s been reimplemented three times and is subtly wrong in two of them.
This is free. Not “free” as in easy. Free as in there’s no framework to adopt. It’s a Kotlin module with no dependencies that compiles to a JVM class file, an Obj-C framework, and a JS bundle. Nothing on the iOS side needs to know it wasn’t written in Swift.
If you take nothing else from this post: the highest-value, lowest-risk code to share is the code where a bug means the two platforms disagree with each other. Money math. Permission checks. Anything where “it works differently on iOS” is a bug report rather than a design choice.
Detent 2: infrastructure
Networking with Ktor, persistence with SQLDelight or Room, preferences with DataStore, dependency injection with Koin or kotlin-inject.
This is where the ecosystem stopped being a promise. It’s also, precisely and deliberately, where Google stops.
Detent 3: state management
Now you’re sharing not just what the app knows but what the app is doing. Loading, error, retry, optimistic update, the half-finished edit the user abandoned.
This is the layer I’ve spent the most time on, so treat me as biased: I wrote ReduxKotlin, and I just shipped a nine-part series on the 1.0 line. A reducer is (State, Action) -> State. It’s a pure function over data classes, which makes it about the most portable code you can write, and it’s why the Redux layer of a KMP app needs almost no platform shim at all.
The payoff isn’t lines of code saved. It’s that “what happens when the network drops mid-save” gets answered once, in one place.
Detent 3 is also the one I’d defend hardest on grounds that have nothing to do with portability. Modeling your UI as a function of state buys you testability, snapshotability and replay as consequences of the shape rather than as features somebody has to build. That argument, its lineage, and the parts of it I still haven’t earned are a whole post of their own.
Detent 4: UI
Compose Multiplatform for iOS went Stable in 1.8.0 in May 2025, with VoiceOver, native text selection, and native-feeling scroll physics. In 1.11.0 (May 2026), concurrent rendering on iOS became the default, and native iOS text input landed as an experimental feature backed by a real UIView.
Desktop is Stable. Web is Beta, and has been since 1.9.0 in September 2025. Don’t put Compose for Web on a roadmap slide without saying “Beta” out loud.
Detent 4 is the one that costs you something real, and here’s where my own 2020 argument comes back to bite me: this is the detent where Jobs’ Flash complaint starts to apply again. You’re no longer rendering UIKit. You’re rendering Skia into a UIKit view. It’s very good and it isn’t the same thing. Ship it where the UI is your product’s logic made visible (dashboards, forms, tools, internal apps) and think harder where the UI is the product.
Detent 5: nearly everything
Block’s Bitkey ships about 95% of its mobile codebase as shared Kotlin, UI included. It’s a real, shipping, security-critical consumer product.
The remaining 5% is the honest number. It never goes to zero, and any framework that tells you it does is lying or is about to.
The shim, and why interop is the actual moat
Every cross-platform story lives or dies on the escape hatch. KMP’s is the best I’ve used, and the reason is boring: the escape hatch is a normal function call.
Kotlin compiles to a real Obj-C framework. Swift imports it like any other framework. There’s no serialization boundary, no bridge thread, no message queue, no JSON encoder sitting in the middle of your hot path. When Swift calls a Kotlin function, that’s a function call.
The idiomatic pattern isn’t the one most people reach for first. expect/actual is the feature everyone learns on day one, and JetBrains’ own docs tell you to use it sparingly:
“As a general rule, rely on standard language constructs wherever possible instead of using expected and actual declarations.”
What they recommend instead is defining an interface in common code and injecting the platform implementation, keeping expect/actual for the DI wiring itself. You don’t lock your design to one implementation per platform, and tests can bind a fake.
// commonMain: the shape of the thinginterface BiometricPrompt { suspend fun authenticate(reason: String): Boolean}
// androidMain: BiometricPrompt from AndroidX// iosMain: LAContext from LocalAuthentication// jvmMain: a stub that returns true, because your desktop build needs to compileCommon code depends on the interface. Each platform binds the real thing. No expect class anywhere, and the day you need two implementations on Android, you aren’t rewriting anything.
The Obj-C bridge is real, and it has real edges
I’m not going to pretend the interop is seamless. Today, Kotlin exports through an Objective-C header, and Swift reads that header, which means Obj-C’s limits become your limits. From JetBrains’ own interop docs:
- Generics only on classes, not interfaces or functions. Type information gets flattened on the way across.
- Swift has no variance, so variance doesn’t survive the trip.
- Non-
finalKotlin classes can’t inherit from Swift/Obj-C types. - Pure Swift modules aren’t consumable from Kotlin unless the API is
@objc-exported. - Kotlin/Swift collection conversions carry a real performance cost.
Your beautiful sealed hierarchy arrives in Swift looking like it lost a fight. Most teams write a thin Swift layer that re-sugars the Kotlin API into something Swift-shaped, and that layer is usually worth having anyway.
Swift export is the fix, and it’s Alpha. It skips the Obj-C header entirely and emits idiomatic Swift. It reached Alpha in Kotlin 2.4, which shipped in June 2026. Today it only works with direct Xcode integration, generics are still erased to their upper bounds, and Swift classes still can’t subclass Kotlin ones. The docs were updated in May 2026 and the label still reads “Alpha … breaking changes are expected.” Watch it. Don’t bet a quarter on it.
Who’s actually paying for this
This matters more than any benchmark, because the graveyard of cross-platform frameworks is mostly full of technically-fine projects whose sponsor lost interest.
Google officially supports KMP. Not “tolerates,” not “is evaluating.” The Android developer docs say it flatly:
“Kotlin Multiplatform (KMP) is officially supported by Google for sharing business logic between Android and iOS. Kotlin Multiplatform is stable and production-ready.”
That began at I/O 2024 and has been backed by shipping work since. Room, DataStore, Lifecycle, ViewModel, Paging, SavedState, Navigation and Navigation3 all have Stable KMP releases on Google’s Tier 1 (Android, JVM, iOS). Google runs KMP in production in the Google Docs app on iOS, and reported in May 2025 that runtime performance came out “on par or better than before.” They upgraded the Kotlin/Native compiler to LLVM 16 and contributed a more efficient garbage collector and string implementation back upstream. That isn’t a blog post. That’s a bill they paid.
Now the part everyone skips. Google’s endorsement stops at detent 2. Their stated scope, repeated in every announcement, is business logic: “Our focus for Jetpack libraries and KMP is on sharing business logic across Android and iOS.” Room still has no web target. The Tier 3 list (watchOS, tvOS, Windows, JS, Wasm) is, in Google’s words, untested on CI with no source or binary compatibility tracking.
I find that reassuring rather than damning, and it’s exactly why I framed this post around a dial. The biggest player in the ecosystem publicly picked a detent, said why, and shipped against it. Detents 3, 4 and 5 exist and people ship them, but you’re choosing those on your own judgment, not on Google’s.
JetBrains declared KMP Stable in November 2023. Android, iOS, desktop JVM, server JVM and JS are Stable; Wasm, watchOS and tvOS are Beta. Their Developer Ecosystem survey puts KMP usage at 7% in 2024 and 18% in 2025. At KotlinConf’26 they reported Kotlin/Native builds 25% faster on less than half the RAM versus a year earlier, measured on the Google Docs codebase. Kotlin 2.4 also shipped SPM Import, which lets Kotlin depend on Swift Package Manager packages, though only where the API is Objective-C-compatible. That lands right as CocoaPods winds down, which is either good planning or good luck.
Who ships it:
- Netflix built Prodicle on KMP. Back in 2020 they reported that almost 50% of their production app code was decoupled from the platform.
- Block ships Bitkey at ~95% shared, and Cash App has been shipping KMP since 2018 (initially behind a feature flag). They gave the community SQLDelight and Zipline, and they fund the Kotlin Foundation.
- Forbes shares over 80% of its business logic across iOS and Android.
- Shopify (a Kotlin Foundation member): “Kotlin Multiplatform is a key part of the mobile tech stack at Shopify.”
- Booking.com, McDonald’s, Duolingo, Philips, Autodesk, Sony, Bolt and Careem all have public KMP case studies, most of them collected on JetBrains’ case-study index.
One note about that list, since I’d rather you hear it from me: it’s selection-biased and you should read it that way. JetBrains curates it, and teams that quietly abandoned KMP don’t write case studies about it. The one concrete public rollback I can point to is Cash App shelving Redwood, their multiplatform reactive-UI layer, whose README now says the project “is no longer under active development.” Note the scope: that’s a retreat from shared UI, by one of KMP’s most invested shops, while the same shop ships Bitkey at 95% shared and keeps Zipline and SQLDelight alive. It’s a data point about detent 4, not about KMP.
The tradeoffs
Anyone selling you a cross-platform story without this section is selling you something.
Your iOS engineers now have to care about Gradle. This is the real adoption tax and it isn’t a technical problem. It’s a “my build is red and I don’t know what a Gradle configuration cache is” problem, and it will burn goodwill faster than any bug. Budget for it. Give the iOS team a fast, well-documented path to a built framework and treat that path as a product.
Common-code incremental compilation in K2 is still an open roadmap item (KT-84567, on the roadmap as of February 2026). Builds have gotten meaningfully better, but they’re not yet where a pure-Swift build is.
The library ecosystem has holes. InfoQ’s evaluation put it well in September 2025: anything close to the metal (advanced Bluetooth, background services, deep platform SDKs) still means writing platform implementations. That’s fine, and it’s what detent 5’s shims are for, but it isn’t what the marketing page describes.
Tooling is good, not native-good. Android Studio with the KMP plugin is a nice place to work. Debugging Kotlin from Xcode is a worse experience than debugging Swift from Xcode, and pretending otherwise helps nobody.
Historical baggage. If you evaluated KMP before Kotlin 1.7.20 (September 2022), you met the old memory model, freeze(), and InvalidMutabilityException. That model was hostile, and it’s gone: the new memory manager became the default in 1.7.20, the legacy one was removed entirely in 1.9.20, and today’s collector is a concurrent mark-and-sweep. A lot of the “we tried KMP and it was awful” opinions in the wild are accurate reports of a runtime that no longer exists. Check the date on every critique you read, including the good ones.
Many platforms is an option, not an obligation
The nine-target matrix is a nice flex and it’s mostly not the point. Most teams want Android and iOS, and the useful thing is that Android and iOS are the two best-supported targets in the ecosystem, from both vendors, at Tier 1.
The rest of the matrix (JVM server, desktop, JS, Wasm, native) is upside you get to take later without re-litigating your architecture. Sharing your domain model with your Ktor backend so the API contract can’t drift is a great trick, and it costs you nothing to leave the door open. But “write once, run on nine platforms” is a slogan, and slogans are what got the previous generation of cross-platform frameworks into trouble. The dial is the point. The target list is just what the dial is wired to.
Agents change the math
I didn’t expect this to be one of the strongest arguments for code sharing.
When your logic lives in one shared module, a coding agent has one codebase to read, one test suite to run, and one place to make a change. When it lives in a Kotlin codebase and a mirrored Swift codebase, the agent has to hold both, make the change twice, keep them semantically identical, and verify against two toolchains. Every one of those is a place to burn tokens and a place to drift.
Sharing code has always paid for itself in bugs-not-written. Now it also pays in tokens-not-spent and loops-not-run, and the two platforms can’t silently disagree because there’s only one of them. It’s why I leaned so hard into making ReduxKotlin’s state inspectable from a CLI: an agent that can dump and diff state as text closes its loop without ever opening a screenshot.
Which means agents argue for turning the dial up. Every detent you climb is one more thing the agent doesn’t have to write twice, verify twice, and keep in sync forever.
So where do you point the dial
If you’re starting today:
- Start at detent 2. Business logic, networking, persistence. It’s where Google stopped, the ecosystem is stable, and the code you share is the code where a disagreement between platforms is a bug rather than a feature.
- Go to 3 when you’re tired of answering “what happens when it fails” twice. That’s what ReduxKotlin 1.0 is for.
- Go to 4 with your eyes open, on a surface where a Skia-rendered UI is genuinely fine. Many surfaces are. Not all.
- Go to 5 when you’ve already done 2, 3 and 4 and the duplication that’s left is still the thing slowing you down. Block did. It took them years.
And when someone asks whether you’re “using a cross-platform framework,” the honest answer is no. You’re writing Kotlin, compiling it natively for each platform, and calling it from Swift like a normal library. The dial just tells you how much.
Happy dial-turning, and may your loops have only one codebase to read.
Sources of truth: kotlinlang.org/docs/multiplatform · developer.android.com/kotlin/multiplatform · JetBrains case studies · reduxkotlin.org