One HTTP Client for Android and iOS: Ktor 3 in a KMP Shared Module

The Networking Problem in KMP Projects

Before Kotlin Multiplatform, Android had Retrofit and OkHttp while iOS had URLSession or Alamofire — two completely separate networking stacks, two sets of models to keep in sync, and twice the bugs to chase. KMP solves this at the shared-module level, and Ktor Client is the library built specifically for it. One set of API calls, one set of data models, shared on both platforms. With the release of Ktor 3 and the latest Ktor 3.4.1, the client is faster, leaner, and fully aligned with modern KMP conventions.

What Changed in Ktor 3

If you’re coming from Ktor 2.x, the biggest difference is under the hood: Ktor 3 migrated its I/O layer from the custom kotlinx.io fork to the official kotlinx-io library (built on Okio). This means better performance, fewer memory allocations, and standardized I/O primitives shared across the Kotlin ecosystem.

For most client-side code, the migration is smooth — your HttpClient setup, plugin installation, and request/response handling all look the same. The changes mainly affect low-level I/O classes like ByteReadChannel and ByteWriteChannel. If you were using those directly, you’ll want to migrate to the kotlinx-io equivalents. The old APIs remain available until Ktor 4.0, so there’s no rush.

One other change worth noting for timeout handling: in a KMP project, you should use Ktor’s own HttpTimeout plugin and catch its multiplatform-safe exceptions — HttpRequestTimeoutException, ConnectTimeoutException, and SocketTimeoutException from the io.ktor.client.plugins package. These work in commonMain across all targets. Avoid catching java.net.SocketTimeoutException directly, as that’s a JVM-only class and won’t compile on iOS.

Setting Up Ktor 3.4.1 in Your KMP Module

The recommended approach in 2026 is to use a Gradle version catalog. Define Ktor once in your gradle/libs.versions.toml and reference it everywhere:

[versions]
ktor = "3.4.1"

[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }

Then in your shared module’s build.gradle.kts, reference the catalog entries:

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.content.negotiation)
            implementation(libs.ktor.serialization.kotlinx.json)
        }
        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
    }
}

Notice the source set syntax: Ktor 3 projects typically use the sourceSets { commonMain.dependencies {} } block style rather than the older val commonMain by getting pattern. Both work, but the newer style is cleaner and is what the Kotlin team recommends. If you’ve followed our guide on how expect and actual work in KMP, you already know how the source set hierarchy fits together.

Creating a Shared API Client

All of your HTTP calls live in commonMain. Here’s a realistic API client that fetches GitHub repositories using kotlinx.serialization for JSON parsing:

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class Repo(
    val name: String,
    @SerialName("stargazers_count") val stargazersCount: Int
)

class GithubApi {
    private val client = HttpClient {
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
                isLenient = true
            })
        }
    }

    suspend fun getRepos(username: String): List {
        return client.get("https://api.github.com/users/$username/repos").body()
    }

    fun close() {
        client.close()
    }
}

No platform-specific code, no expect/actual needed for the API layer. GithubApi compiles to both the Android AAR and the iOS framework exactly as written. Your Android ViewModel and your iOS SwiftUI view model both call the same getRepos() function.

How Ktor Client Picks the Right Engine

Ktor’s engine selection happens automatically via the HttpClient() constructor — it picks up whichever engine is on the classpath for the current target. On Android that’s OkHttp, on iOS it’s Darwin (which wraps NSURLSession).

If you need platform-specific engine configuration, use expect/actual to create the client explicitly:

// commonMain
expect fun createPlatformHttpClient(): HttpClient

// androidMain
import io.ktor.client.engine.okhttp.*

actual fun createPlatformHttpClient(): HttpClient = HttpClient(OkHttp) {
    engine {
        config {
            retryOnConnectionFailure(true)
            connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
        }
    }
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
}

// iosMain
import io.ktor.client.engine.darwin.*

actual fun createPlatformHttpClient(): HttpClient = HttpClient(Darwin) {
    engine {
        configureRequest {
            setAllowsCellularAccess(true)
        }
    }
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
}

This pattern gives you full control over each platform’s networking behavior while keeping the shared API surface identical. We covered the mechanics of expect/actual in depth in our KMP expect and actual post.

Coroutines on iOS: Bridging to Swift

Ktor’s suspend functions work on iOS, but Swift doesn’t natively understand Kotlin coroutines. You need a bridging layer. The two most popular approaches in 2026 are:

  • SKIE by Touchlab — automatically generates Swift-friendly async/await wrappers for your Kotlin suspend functions. Zero boilerplate on your side.
  • KMP-NativeCoroutines — a KSP plugin that generates AsyncStream and async wrappers. More configuration, but very flexible.

Either way, your shared Ktor client stays clean. The bridging happens at the framework boundary, not inside your networking code.

Summary

Ktor 3.4.1 is the most practical choice for shared networking in a KMP project. You write your API calls once in commonMain, let Ktor pick the right engine per platform, and eliminate an entire category of Android/iOS divergence. The migration from Ktor 2.x is minimal for client-side code — bump the version, adopt the version catalog, and update any low-level I/O code if you were using it. Pair it with kotlinx.serialization for JSON and you have a complete, shared data-fetching stack with no platform-specific surprises.


This post was written by a human with the help of Claude, an AI assistant by Anthropic.

Scroll to Top