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.

March 18, 202613 min readBy LevnTech Team

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:

LayerShared?Percentage
Data models / DTOsYes100%
Networking / API clientYes90-95%
Business logic / use casesYes85-95%
Local database / cachingYes80-90%
Analytics / loggingYes90-100%
View models / presentation logicPartially50-70%
UI componentsNo0%
Platform APIsNo0%

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:

PurposeLibraryNotes
HTTP clientKtorFull KMP support, engine per platform
SerializationKotlinx.serializationJSON, Protobuf, CBOR
DatabaseSQLDelightType-safe SQL, migrations
AsyncKotlinx.coroutinesFull multiplatform support
Date/timeKotlinx.datetimeReplaces platform-specific APIs
DIKoinFull KMP support
Settings/prefsMultiplatform SettingsSharedPreferences / NSUserDefaults
Image loadingCoil 3KMP support (Android, iOS, JVM, JS)
NavigationVoyager or DecomposeFor shared navigation logic
LoggingNapier or KermitPlatform-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-kotlin plugin 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.

AspectKMPFlutterReact Native
What's sharedBusiness logicEverything (UI + logic)Everything (UI + logic)
UI approachNative per platformFlutter widgetsReact Native components
UI fidelityHighest (truly native)High (custom rendered)High (native components)
Code sharing50-70%85-95%80-90%
Learning curveModerate (Kotlin)Moderate (Dart)Low-Moderate (JS/React)
Team structureNative devs who collaborateSingle cross-platform teamSingle cross-platform team
Best forApps that must feel 100% nativeCustom-UI-heavy appsReact-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

  1. Start with data models: Extract @Serializable data classes into a shared KMP module. Both platforms consume the same models.
  2. Move networking: Replace platform-specific HTTP clients with Ktor in the shared module. API contracts are now guaranteed identical.
  3. Share business logic: Move validation rules, calculations, and state machines. Write shared tests to verify correctness.
  4. Share persistence: Migrate to SQLDelight for shared database operations.
  5. 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:

  1. Pick a single, well-defined module (authentication, analytics, or data models)
  2. Set up the KMP Gradle project with Android and iOS targets
  3. Implement the shared logic with comprehensive tests
  4. Integrate with both native apps and measure developer velocity
  5. 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