ReadThat 11: Kotlin Multiplatform
Where sharing code across Android, iOS, and the web actually pays: nearly everything can be shared, including UI, but platform nativeness, web toolchain friction, and Wasm TTI overhead decide what should be. Plus why shared code is a force multiplier for AI agents.
Part 11 of the ReadThat case study.
📲 Try it live: Download the ReadThat APK (8.3 MB, Android 8+). Open the file on your phone and allow “install unknown apps” if prompted.
Can vs. should
The state of Kotlin Multiplatform in one sentence: nearly all code can be shared across Android, iOS, and the web, including the UI via Compose Multiplatform. That does not mean it should be.
The interesting engineering question moved. It used to be “what is technically shareable?” and the answer kept growing until it covered almost everything. Now it is “where does sharing serve the user, and where does it cost them?” That is a per-layer, even per-feature decision, and ReadThat is built as an explicit answer to it. (The framing continues Kotlin Multiplatform Is a Dial, Not a Switch: one dial per feature, not one switch per app.)
KMP is production-ready for shared non-UI code
My position after building ReadThat and shipping KMP elsewhere: KMP has matured to the point where shared non-UI code can ship to production in a high-scale application. The layers that pay off immediately:
- Data models and the data layer. Wire contracts, validation, and serialization written once, identical on every platform. Divergent parsing bugs (the classic “iOS parses this field differently” class) stop existing.
- Business logic. Reducers, engines, and policy. ReadThat’s
:core:modelholds the contracts, pure reducers, the SDUI wire model and flatteners, and the comment tree domain, compiled and tested for Android, iOS device/simulator, and browser JS (shared/). Since the convergence,:core:clientadds shared MVVM controllers and offline-first repositories, and:core:dataputs the Room schema itself in commonMain. - Utility classes and abstractions. The undramatic bulk of a codebase: formatting, pagination math, cache keys, feature gates.
- Telemetry. ReadThat’s
:core:observabilityis a KMP event contract with monotonic timers and platform clocks per target (observability/). One metric vocabulary across three platforms is what makes the measurement contract comparable at all; per-platform telemetry implementations drift into incomparable numbers.
The payoff compounds: less code overall, and bugs fixed once fix everywhere. A vote-coalescing race repaired in shared logic is repaired on Android, iOS, and web in the same commit.
The cost: knowing when to drop down
Sharing is not free. The recurring complexity is deciding when to drop down to a platform-specific implementation, and building the seam so the drop-down is clean:
- ReadThat’s transport is
expect-shaped but platform-real: Android usesHttpEngine/OkHttp (part 9); iOS uses one long-lived URLSession with native HTTP/2/HTTP/3 state and default-path migration. The policy (TLS floor, idempotency rules, cache identity) is shared; the engine is not. - Media, background scheduling (WorkManager vs
BGAppRefreshTask), keystores, and pickers all follow the same rule: shared contract, platform muscle. - The judgment call has to live somewhere. Teams adopting KMP should expect to spend real design energy on where the boundary sits, because a boundary in the wrong place turns “shared” into “lowest common denominator.”
Shared UI: the tradeoff is the user
Compose Multiplatform can render the whole app on iOS today, and for many apps that is the right call. For a product where feel is the product, the tradeoff of platform nativeness is real: scroll physics, navigation transitions, text editing, accessibility integration. When those matter, dropping to native UI is the better call for the user. This is a dial setting, not a doctrine; a settings screen and an onboarding flow can sit at different detents.
ReadThat’s current iOS build is the shared-UI detent, running for real:
The same feed as the Android capture, on an iPhone: a native SwiftUI lifecycle host around a shared KMP framework, where the feed/detail/search screens are shared Compose UI. Native code is reserved for the seams where the Apple implementation is the optimization: Keychain, PhotosUI, share sheets, and AVPlayer/HLS for video.
AI agents change the math
A newer argument for sharing, and increasingly the one I weight most: agents are demonstrably good at writing shared Kotlin. The economics stack up:
- Less code is less tokens. One shared implementation is one context window’s worth of code instead of three, for generation, review, and every future read.
- One fix, multiple platforms. An agent that repairs a reducer bug ships the repair to Android, iOS, and web at once; there is no “now port the fix” work item.
- Faster movement in general. Pure shared logic with fast host tests is exactly the surface agents verify best (the ReduxKotlin agents post covers why verifiability is the bottleneck). ReadThat’s common suites are configured for Android host, iOS simulator, and—where the module supports it—browser JS, so the same implementation can be checked on each target instead of assuming an Android pass proves iOS.
The web: where I stop sharing (for now)
Kotlin can reach the browser via JS and Wasm, and ReadThat’s KMP contracts do compile for browser JS. But for the frontend itself, ReadThat’s web client is deliberately React + TypeScript (www/), and that is my recommendation for high-scale web apps today:
- Wasm costs TTI. Payload size plus module initialization overhead land exactly where the web is most unforgiving; the p75 LCP ≤ 2.5 s budget in part 7 has no room for a runtime download before first paint.
- Toolchain friction. Web development is at its most nimble in years: instant HMR, edge deploys measured in seconds, an ecosystem tuned end to end for that loop. Kotlin compilation and Wasm bundling sit outside that optimization path and reintroduce friction the frontend world spent a decade removing.
- The interesting Kotlin/Wasm use cases exist but are narrow: compute-heavy shared engines (an editor core, a rules engine, offline sync logic) embedded inside an otherwise-native web app. For page-shaped, TTI-sensitive surfaces, share the contracts (types, validation, telemetry vocabulary) and let the web be the web.
Where ReadThat landed
| Layer | Android | iOS | Web |
|---|---|---|---|
Contracts, reducers, validation (:core:model) | shared | shared | KMP JS target; React app mirrors the wire contract |
MVVM/controllers/repositories (:core:client) | shared | shared | not used by React |
Telemetry contract (:core:observability) | shared | shared | KMP JS target; React emits the same schema |
| Data layer / storage | Room 3 KMP (shared schema, commonMain) | Room 3 KMP (same schema) | IndexedDB |
| Transport | HttpEngine/OkHttp | URLSession | fetch |
| UI | Jetpack Compose | shared Compose in a SwiftUI host (native media/system seams) | React + TS |
Shared where sharing removes bugs and code; native where the platform is the feature; web left on its own toolchain until Wasm earns its payload. That is the dial, set deliberately, per layer.
← Part 10: Kotlin Flows in practice · Next: Part 12: The web client →