Ktor Client End-to-End: From Engine Abstraction to a Coroutine-Powered Network Layer in Kotlin Multiplatform

The Selection Gap Beyond OkHttp

While building the shared network layer in a KMP project, I ran into an unavoidable problem: OkHttp only covers Android and the JVM, so iOS needs a separate NSURLSession wrapper written from scratch. Write it twice for the two platforms and the behavior still diverges — when a timeout bug appears, the two sides won’t line up.

There were two paths in front of me: write one implementation per platform with expect/actual, or bring in a truly cross-platform client. The former doubles the workload immediately; the latter led me to take a serious look at Ktor Client.

Ktor Client’s value isn’t in any specific engine, but in the way it abstracts “sending a request” into a unified API, turning the underlying engine into a replaceable configuration. That abstraction is exactly what a KMP network layer is missing.

Engine Abstraction: HttpClient and HttpClientEngine

HttpClient is the facade you use day to day; it doesn’t handle any byte streams itself. What actually establishes the connection and sends the message is the HttpClientEngine interface, which has an implementation per platform:

EnginePlatformCharacteristics
OkHttpAndroid/JVMMature and stable, HTTP/2 + connection pool
DarwiniOSWraps NSURLSession
CIOAll platformsPure Kotlin implementation, zero platform dependencies
JsJSWraps fetch/XHR

Choosing an engine is just a matter of passing one parameter:

val client = HttpClient(OkHttp) {
    // 通用配置
}

In KMP, I’m used to pushing engine selection down into each platform source set with expect/actual, so the common layer only depends on the shared HttpClient type:

// commonMain
expect fun createHttpClient(): HttpClient

// androidMain
actual fun createHttpClient() = HttpClient(OkHttp)

// iosMain
actual fun createHttpClient() = HttpClient(Darwin)

The common layer is completely unaware of which engine sits underneath. An iOS build links Darwin, an Android build links OkHttp, and neither interferes with the other.

Interceptor Pipelines: Requests and Responses Are Two Assembly Lines

Engine abstraction answers “who sends the request”; the interceptor pipeline answers “what to do before sending and after receiving.” Ktor splits a single call into two pipelines:

  • HttpRequestPipeline: Before → State → Transform → Render → Send
  • HttpResponsePipeline: Receive → Parse → Transform → State → After

Built-in plugins hook into their corresponding stages. DefaultRequest appends common parameters at the outermost layer of the request, HttpTimeout controls timeouts, and ContentNegotiation performs serialization at the Transform stage:

val client = HttpClient(OkHttp) {
    install(DefaultRequest) {
        url("https://api.example.com/")
        header("X-Client-Version", BuildConfig.VERSION)
    }
    install(HttpTimeout) {
        requestTimeoutMillis = 10_000
    }
}

HttpSend is the last gate of the request pipeline, sitting right up against the engine. If you want to add custom interception logic, hook into it directly:

client.plugin(HttpSend).intercept { request ->
    val start = System.currentTimeMillis()
    val response = execute(request)
    log("${request.method} ${request.url} -> ${response.status} (${System.currentTimeMillis() - start}ms)")
    response
}

This is lighter than writing a full HttpClientPlugin, and suits cross-cutting needs like logging, telemetry, and request rewriting.

Going Coroutine-Native: suspend and Flow Across the Entire Pipeline

Every request method in Ktor Client is a suspend function, so cancellation, timeouts, and concurrency control all reuse the capabilities of coroutines directly:

suspend fun fetchUser(id: String): UserDto {
    return client.get("users/$id").body()
}

body() works with kotlinx.serialization for deserialization, provided ContentNegotiation is installed:

val client = HttpClient(OkHttp) {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
}

Wrap another Flow at the repository layer, and network data can be exposed to upper layers in a reactive way:

fun observeUsers(): Flow<List<User>> = flow {
    emit(client.get("users").body())
}

Coroutine cancellation propagates all the way down the call chain to the engine, and in-flight IO is interrupted promptly. This is more comfortable than a callback-style network layer — in callback mode, once the page is destroyed, you still have to manually decide whether a callback should run.

Practical Implementation: Engine Selection, Timeouts, and MockEngine

At the implementation stage, what you actually have to decide are a few concrete questions.

Engine selection: On Android I lean toward OkHttp. It has been battle-tested across a massive number of devices, and its connection pool and HTTP/2 behavior are mature. CIO is reserved for scenarios like desktop apps and server-side scripts where you don’t want to introduce platform dependencies. On iOS there’s no real choice — Darwin is the only practical option.

Timeout semantics: HttpTimeout’s three parameters are easy to mix up. connectTimeoutMillis is the connection-establishment timeout, socketTimeoutMillis is the wait timeout between bytes, and requestTimeoutMillis is the total time budget for the entire request. Configure all three, and online troubleshooting gets a lot easier.

Testing: MockEngine lets you verify the complete pipeline, including serialization, without touching a real network:

val mockEngine = MockEngine { request ->
    respond(
        content = """{"id":"1","name":"kevin"}""",
        status = HttpStatusCode.OK,
        headers = headersOf(HttpHeaders.ContentType, "application/json")
    )
}
val client = HttpClient(mockEngine) {
    install(ContentNegotiation) { json() }
}

A pitfall I’ve stepped in: when MockEngine returns a response, if you omit Content-Type: application/json, ContentNegotiation can’t find the serializer and body() throws immediately. In a real network environment the server usually carries this header, but in a Mock you have to add it yourself.

Remember to call close() on the client when you’re done. Both the OkHttp and Darwin engines hold a connection pool under the hood; in KMP the client is often made into a long-lived singleton, but when process-level resources are being released, it should still be closed.

Ktor Client trades engine abstraction for cross-platform support, at the cost of needing to understand each engine’s boundaries. It doesn’t flatten platform differences — it moves them to an explicit location. For me, that’s more controllable than hiding the differences away.