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:
| Engine | Platform | Characteristics |
|---|---|---|
OkHttp | Android/JVM | Mature and stable, HTTP/2 + connection pool |
Darwin | iOS | Wraps NSURLSession |
CIO | All platforms | Pure Kotlin implementation, zero platform dependencies |
Js | JS | Wraps 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.