Kotlin Multiplatform: Share Code Between Android, iOS, and Web
A practical guide to Kotlin Multiplatform (KMP) — how it works, what you can share across platforms, and when it makes sense over Flutter or React Native.
Kotlin Multiplatform (KMP) represents a fundamentally different philosophy from Flutter or React Native. Rather than replacing your native UI with a cross-platform framework, KMP lets you share business logic — networking, data models, validation, caching, analytics — while keeping fully native UIs on each platform.
JetBrains officially marked Kotlin Multiplatform as Stable in November 2023, and since then adoption has accelerated. Netflix, VMware, Philips, Cash App, and McDonald's all use KMP in production. Google declared KMP its recommended approach for sharing business logic in Android and multiplatform projects.
This guide covers how KMP works, what you can realistically share, and how to structure a project that scales across Android, iOS, web, and server.
How Kotlin Multiplatform Works
KMP is not a framework — it is a compiler feature. The Kotlin compiler can target multiple backends:
- Kotlin/JVM — compiles to JVM bytecode (Android, server-side)
- Kotlin/Native — compiles to native binaries via LLVM (iOS, macOS, Linux, Windows)
- Kotlin/JS — compiles to JavaScript (browser, Node.js)
- Kotlin/Wasm — compiles to WebAssembly (emerging, experimental)
When you write shared Kotlin code, the compiler generates platform-appropriate output for each target. Your Android app consumes the shared code as a regular Kotlin library. Your iOS app consumes it as a Swift-compatible XCFramework. Your web app consumes it as a JavaScript module.
The expect/actual Mechanism
KMP handles platform differences through expect/actual declarations. You declare what you need in shared code, then provide platform-specific implementations:
// Shared code (commonMain)
expect fun platformName(): String
expect class SecureStorage {
fun save(key: String, value: String)
fun read(key: String): String?
}
// Android implementation (androidMain)
actual fun platformName(): String = "Android ${Build.VERSION.RELEASE}"
actual class SecureStorage {
private val prefs = EncryptedSharedPreferences.create(...)
actual fun save(key: String, value: String) { prefs.edit().putString(key, value).apply() }
actual fun read(key: String): String? = prefs.getString(key, null)
}
// iOS implementation (iosMain)
actual fun platformName(): String = "iOS ${UIDevice.currentDevice.systemVersion}"
actual class SecureStorage {
actual fun save(key: String, value: String) { /* Keychain API */ }
actual fun read(key: String): String? { /* Keychain API */ }
}
This is more surgical than Flutter or React Native's approach. You share exactly what makes sense to share and go native where native is better.
What You Can (and Should) Share
Not everything belongs in shared code. The value of KMP is being selective about what crosses platform boundaries.
High-Value Shared Code
Networking layer: HTTP clients, API request/response models, authentication token management, retry logic. Ktor (JetBrains' HTTP client) is built for KMP and supports all targets.
Data models and validation: Your User, Order, Product entities and their validation rules should exist exactly once. Kotlinx.serialization handles JSON encoding/decoding across all platforms.
Business logic: Tax calculations, pricing rules, eligibility checks, state machines — anything that should behave identically regardless of platform.
Local database: SQLDelight generates type-safe Kotlin APIs from SQL statements and supports SQLite on Android, iOS, and JS. Your database schema and queries are defined once.
Analytics and event tracking: Event names, parameters, and trigger conditions defined in shared code ensure consistent analytics across platforms.
Feature flags and configuration: Remote config parsing, A/B test assignment logic, and feature gate evaluation.
Keep Platform-Native
UI layer: Each platform has its own UI toolkit (Jetpack Compose on Android, SwiftUI on iOS, React on web). KMP deliberately does not replace these.
Platform-specific APIs: Camera, Bluetooth, GPS, push notifications, biometrics — use the native APIs through expect/actual or platform-specific modules.
Navigation: Each platform has its own navigation paradigm. Trying to abstract navigation across platforms creates more problems than it solves.
The Sharing Spectrum
In practice, mature KMP projects share between 50-70% of their codebase. Here is a realistic breakdown:
| Layer | Shared? | Percentage |
|---|---|---|
| Data models / DTOs | Yes | 100% |
| Networking / API client | Yes | 90-95% |
| Business logic / use cases | Yes | 85-95% |
| Local database / caching | Yes | 80-90% |
| Analytics / logging | Yes | 90-100% |
| View models / presentation logic | Partially | 50-70% |
| UI components | No | 0% |
| Platform APIs | No | 0% |
Setting Up a KMP Project
Project Structure
A standard KMP project uses Gradle with the Kotlin Multiplatform plugin:
project/
shared/ # KMP shared module
src/
commonMain/ # Shared code
commonTest/ # Shared tests
androidMain/ # Android-specific implementations
iosMain/ # iOS-specific implementations
jsMain/ # Web-specific implementations
build.gradle.kts
androidApp/ # Android application
iosApp/ # iOS/SwiftUI application
webApp/ # Web application (optional)
Essential Libraries for KMP
The KMP ecosystem has matured significantly. These libraries are production-tested:
| Purpose | Library | Notes |
|---|---|---|
| HTTP client | Ktor | Full KMP support, engine per platform |
| Serialization | Kotlinx.serialization | JSON, Protobuf, CBOR |
| Database | SQLDelight | Type-safe SQL, migrations |
| Async | Kotlinx.coroutines | Full multiplatform support |
| Date/time | Kotlinx.datetime | Replaces platform-specific APIs |
| DI | Koin | Full KMP support |
| Settings/prefs | Multiplatform Settings | SharedPreferences / NSUserDefaults |
| Image loading | Coil 3 | KMP support (Android, iOS, JVM, JS) |
| Navigation | Voyager or Decompose | For shared navigation logic |
| Logging | Napier or Kermit | Platform-aware logging |
Configuring the Shared Module
// shared/build.gradle.kts
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
js(IR) { browser() }
sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:3.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("app.cash.sqldelight:runtime:2.1.0")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:3.0.0")
implementation("app.cash.sqldelight:android-driver:2.1.0")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:3.0.0")
implementation("app.cash.sqldelight:native-driver:2.1.0")
}
}
}
Building a Real Feature with KMP
Let's walk through implementing a user authentication module that is shared across Android and iOS.
Shared Domain Layer
// commonMain/domain/AuthRepository.kt
interface AuthRepository {
suspend fun login(email: String, password: String): Result<AuthToken>
suspend fun refreshToken(token: AuthToken): Result<AuthToken>
suspend fun logout()
fun isLoggedIn(): Flow<Boolean>
}
// commonMain/domain/AuthToken.kt
@Serializable
data class AuthToken(
val accessToken: String,
val refreshToken: String,
val expiresAt: Instant
) {
fun isExpired(): Boolean = Clock.System.now() > expiresAt
}
Shared Data Layer
// commonMain/data/AuthRepositoryImpl.kt
class AuthRepositoryImpl(
private val api: AuthApi,
private val tokenStorage: TokenStorage,
) : AuthRepository {
override suspend fun login(email: String, password: String): Result<AuthToken> {
return runCatching {
val response = api.login(LoginRequest(email, password))
val token = response.toAuthToken()
tokenStorage.save(token)
token
}
}
override fun isLoggedIn(): Flow<Boolean> =
tokenStorage.observeToken().map { it != null && !it.isExpired() }
}
Platform-Specific Token Storage
// commonMain
expect class TokenStorage {
suspend fun save(token: AuthToken)
suspend fun clear()
fun observeToken(): Flow<AuthToken?>
}
// androidMain — uses EncryptedSharedPreferences
actual class TokenStorage(context: Context) { /* ... */ }
// iosMain — uses Keychain
actual class TokenStorage() { /* ... */ }
The business logic — login flow, token refresh, expiry checks — lives in shared code. Only the secure storage mechanism varies by platform. Both the Android (Jetpack Compose) and iOS (SwiftUI) apps call the same AuthRepository interface.
iOS Interoperability Deep Dive
The biggest concern teams raise about KMP is iOS interoperability. Kotlin/Native compiles to a framework that Swift can consume, but the API surface does not always translate cleanly.
Challenges and Solutions
Kotlin coroutines in Swift: Kotlin suspend functions are exposed to Swift as async/await functions (since Kotlin 1.7+). This works naturally in modern Swift code.
Kotlin Flow in Swift: Flows do not automatically map to Swift's AsyncSequence. Use the SKIE plugin (by Touchlab) to generate Swift-friendly wrappers that expose Kotlin Flows as Swift AsyncSequence.
Generics: Kotlin generics are partially erased in Objective-C interop. SKIE and KMP-NativeCoroutines solve most of these edge cases.
Nullability: Kotlin's null safety maps well to Swift optionals. This is generally smooth.
Enums: Kotlin sealed classes are exposed as Objective-C class hierarchies. SKIE generates proper Swift enums with exhaustive switch support.
Tooling for iOS Developers
- SKIE (by Touchlab): Dramatically improves the Swift API surface generated from Kotlin
- KDoctor: Diagnostics tool that validates your KMP development environment
- Xcode integration: The
xcode-kotlinplugin provides debugging support for Kotlin code from Xcode - CocoaPods or SPM: KMP frameworks can be distributed via CocoaPods (built-in support) or Swift Package Manager (via custom configuration)
KMP vs. Flutter vs. React Native
Understanding where KMP fits relative to other cross-platform approaches is essential for making the right architectural decision.
| Aspect | KMP | Flutter | React Native |
|---|---|---|---|
| What's shared | Business logic | Everything (UI + logic) | Everything (UI + logic) |
| UI approach | Native per platform | Flutter widgets | React Native components |
| UI fidelity | Highest (truly native) | High (custom rendered) | High (native components) |
| Code sharing | 50-70% | 85-95% | 80-90% |
| Learning curve | Moderate (Kotlin) | Moderate (Dart) | Low-Moderate (JS/React) |
| Team structure | Native devs who collaborate | Single cross-platform team | Single cross-platform team |
| Best for | Apps that must feel 100% native | Custom-UI-heavy apps | React-ecosystem apps |
Choose KMP when:
- You already have native Android and iOS teams
- Your app must adhere strictly to platform UI guidelines (banking, healthcare)
- You want to incrementally share code without rewriting existing apps
- UI fidelity and platform-native behavior are non-negotiable
Choose Flutter or React Native when:
- You want maximum code sharing (including UI)
- You have a small team that cannot staff separate platform specialists
- Custom UI is preferred over platform-standard components
- Time-to-market is the primary constraint
For teams evaluating these options, our mobile app development services can provide framework-specific recommendations based on your team composition and product requirements.
Testing Strategy for KMP
KMP's testing story is one of its underappreciated strengths. Shared code can be tested once and verified across all platforms.
Unit Tests in commonTest
// commonTest/AuthRepositoryTest.kt
class AuthRepositoryTest {
private val fakeApi = FakeAuthApi()
private val fakeStorage = FakeTokenStorage()
private val repo = AuthRepositoryImpl(fakeApi, fakeStorage)
@Test
fun loginSuccessStoresToken() = runTest {
fakeApi.nextResponse = LoginResponse(accessToken = "abc", refreshToken = "xyz")
val result = repo.login("user@test.com", "password123")
assertTrue(result.isSuccess)
assertNotNull(fakeStorage.currentToken)
}
}
These tests run on JVM (fast, CI-friendly), and the same assertions validate behavior on all target platforms. Platform-specific implementations (expect/actual) are tested separately in androidTest and iosTest source sets.
Integration Testing
For API integration tests, use Ktor's MockEngine to simulate HTTP responses in shared test code. For database tests, SQLDelight provides in-memory drivers for all platforms.
Production Deployment Considerations
App Size Impact
The KMP shared module adds approximately 2-4 MB to your iOS app (the Kotlin/Native runtime) and negligible size to your Android app (Kotlin is already included). This is significantly smaller than Flutter's overhead (~10-15 MB) because KMP does not include a rendering engine.
Build Times
KMP builds are incremental. After the initial compilation, rebuilds of the shared module take 5-15 seconds on a modern machine. iOS framework generation is the slowest step — enable the KOTLIN_FRAMEWORK_BUILD_TYPE=debug flag during development to speed this up.
Debugging
Kotlin code can be debugged from Android Studio (all platforms) and Xcode (iOS, with the xcode-kotlin plugin). Breakpoints, variable inspection, and step-through debugging work across the shared module.
Migrating an Existing App to KMP
The migration path for KMP is one of its greatest strengths. Unlike Flutter or React Native, which typically require significant UI rewrites, KMP can be adopted incrementally.
Step-by-Step Migration
- Start with data models: Extract
@Serializabledata classes into a shared KMP module. Both platforms consume the same models. - Move networking: Replace platform-specific HTTP clients with Ktor in the shared module. API contracts are now guaranteed identical.
- Share business logic: Move validation rules, calculations, and state machines. Write shared tests to verify correctness.
- Share persistence: Migrate to SQLDelight for shared database operations.
- Optionally share view models: Using Decompose or KMP-ViewModel, share presentation logic while keeping UI native.
Each step is independently deployable. You can ship an app that shares just data models while the rest remains native, and expand sharing over time.
Common Pitfalls and How to Avoid Them
Over-sharing: Not every line of code should be shared. UI-adjacent logic that depends heavily on platform behavior (animation timing, gesture handling) is better left native.
Ignoring iOS developer experience: If your iOS team finds the Kotlin-generated Swift APIs awkward to use, adoption will fail. Invest in SKIE and review the generated API surface with your iOS developers.
Neglecting Kotlin/Native memory model: Kotlin/Native uses a different memory model than JVM Kotlin. As of Kotlin 1.7.20+, the new memory manager handles most cases automatically, but you should understand its implications for multithreaded code.
Monolithic shared module: As the shared codebase grows, split it into feature-specific modules (shared:auth, shared:networking, shared:analytics). This improves build times and enforces module boundaries.
Getting Started
If you are evaluating KMP for your organization, start with a focused pilot:
- Pick a single, well-defined module (authentication, analytics, or data models)
- Set up the KMP Gradle project with Android and iOS targets
- Implement the shared logic with comprehensive tests
- Integrate with both native apps and measure developer velocity
- Expand sharing based on what worked and what did not
Our team has implemented KMP for clients who needed to maintain native UIs while eliminating code duplication across platforms. Contact us to discuss whether KMP fits your architecture.
For a broader comparison of cross-platform options, see our React Native vs Flutter analysis, which covers the full-framework alternatives to KMP's shared-logic approach.
FAQ
Does KMP replace the need for native Android and iOS developers?
No — and that is by design. KMP requires developers who understand each platform. The shared Kotlin code handles business logic, but you still need Android developers building Jetpack Compose UI and iOS developers building SwiftUI interfaces. What KMP eliminates is the duplication of business logic, networking, and data layer code across those teams.
How mature is the KMP ecosystem compared to Flutter or React Native?
The core KMP technology is stable and production-ready, backed by JetBrains and Google. The library ecosystem is smaller than Flutter's pub.dev or React Native's npm, but essential libraries (Ktor, SQLDelight, Kotlinx.serialization, Koin) are mature and widely used. For niche needs, you may need to write platform-specific code and expose it via expect/actual, which is straightforward but requires platform knowledge.
Can I use Compose Multiplatform for shared UI instead of keeping native UIs?
Yes. Compose Multiplatform (by JetBrains) brings Jetpack Compose's UI model to iOS, desktop, and web. It is stable for desktop and web, and has reached stable status for iOS as of 2025. If your team prefers Compose-style declarative UI and accepts that iOS UI will look Compose-styled rather than SwiftUI-native, Compose Multiplatform combined with KMP gives you a Flutter-like code sharing level with the Kotlin ecosystem.
What is the performance overhead of KMP on iOS?
Minimal. Kotlin/Native compiles to native ARM machine code via LLVM — the same compiler backend that Swift uses. Runtime performance is comparable to Swift for most operations. The Kotlin/Native runtime adds approximately 2-4 MB to the app size. Coroutine suspension and Flow collection have negligible overhead. The primary performance consideration is interop overhead at the Kotlin-Swift boundary, which is minimal for well-designed APIs with clear data boundaries.
Need Help With Your Project?
Our team of experts is ready to help you build, grow, and succeed. Get a free consultation today.
Book Free Consultation