Case study

ReadThat 11: Kotlin Multiplatform

5 min read

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.8 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:model holds auth/post/profile/settings/video-policy contracts and pure reducers, compiled and tested for Android, iOS device/simulator, and browser JS (shared/).
  • Utility classes and abstractions. The undramatic bulk of a codebase: formatting, pagination math, cache keys, feature gates.
  • Telemetry. ReadThat’s :core:observability is 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 uses HttpEngine/OkHttp (part 9), iOS uses one long-lived URLSession with native connection migration (clients/ios). 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, sharing the UI is not the best thing for the user, and ReadThat’s iOS direction reflects that: shared core, native presentation. This is a dial setting, not a doctrine; a settings screen and an onboarding flow can sit at different detents.

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 JVM-fast tests is exactly the surface agents verify best (the ReduxKotlin agents post covers why verifiability is the bottleneck). ReadThat’s KMP suites run on every platform target in CI, so an agent gets a same-commit answer to “did I break iOS?” without owning a Mac toolchain.

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

LayerAndroidiOSWeb
Contracts, reducers, validation (:core:model)sharedsharedshared (JS)
Telemetry contract (:core:observability)sharedsharedshared (JS)
Data layer / storageRoomplatform storeIndexedDB
TransportHttpEngine/OkHttpURLSessionfetch
UIJetpack Composenative (SwiftUI direction)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 · Back to the series hub →