
In endurance racing, the pit crew operates on a contract the driver never has to think about. Every lap, the same checks. Every stop, the same sequence: tyres, fuel, wing, go. The driver focuses on driving. The crew handles everything else.
I’ve been using Ktor as the HTTP layer in my KMP projects for a while now, and the moment things clicked for me was when I stopped thinking of it as “a way to make HTTP requests” and started thinking of it as a pluggable pipeline. Every request passes through a crew of plugins before it goes out, and every response passes back through them before your code sees it. Authentication tokens, request logging, retry logic on transient failures. None of that belongs in your repository or your ViewModel. It belongs in the client configuration, running invisibly on every call.
I wrote about Ktorfit earlier as a way to get Retrofit-style interfaces over Ktor. This article is about the engine underneath that: the raw HttpClient, how to configure it for real-world use, and the three areas where most projects run into trouble: auth, logging, and retry.
The HttpClient is built at startup, used everywhere
The key rule with Ktor’s HttpClient is that you configure it once and share the single instance across your app. Not one client per feature module, not a new client per request. One client, configured at startup, injected wherever it’s needed.
// commonMain/network/HttpClientFactory.kt
fun createHttpClient(): HttpClient = HttpClient {
// All configuration goes here
}
If you’re using Koin, register it as a singleton:
// commonMain/di/NetworkModule.kt
val networkModule = module {
single { createHttpClient() }
}
The plugins you install here run on every request made through this client. That’s the point: you configure the cross-cutting behaviour once, in one place, and the rest of your code stays clean.
Setup
The core Ktor client and the plugins you’ll need:
# gradle/libs.versions.toml
[versions]
ktor = "3.1.3"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", 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" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", 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" }
// shared/build.gradle.kts
plugins {
// ...
alias(libs.plugins.kotlinSerialization)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp) // OkHttp engine on Android
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin) // NSURLSession engine on iOS
}
}
}
A quick note on project structure: The new Compose Multiplatform template from JetBrains places shared module dependencies in
shared/build.gradle.kts, notcomposeApp/. If your project uses an older template, the sourcesets and dependency declarations are identical. Only the module name differs.
The engine is platform-specific: OkHttp on Android, NSURLSession on iOS. Everything else lives in commonMain.
Logging
The first plugin to add is logging. You want to see what’s going over the wire before you spend two hours wondering why a request is failing:
// commonMain/network/HttpClientFactory.kt
import io.ktor.client.plugins.logging.*
fun createHttpClient(): HttpClient = HttpClient {
install(Logging) {
logger = Logger.DEFAULT // prints to the platform logger
level = LogLevel.ALL // HEADERS includes request/response headers; ALL adds the body
}
}
LogLevel.ALL is useful during development: you see everything. In production, drop it to LogLevel.NONE or LogLevel.INFO. The body logging at ALL can be verbose enough to slow things down noticeably if you’re hitting large responses repeatedly.
I keep the level behind a build config flag:
// commonMain/network/HttpClientFactory.kt
fun createHttpClient(isDebug: Boolean): HttpClient = HttpClient {
install(Logging) {
logger = Logger.DEFAULT
level = if (isDebug) LogLevel.ALL else LogLevel.NONE
}
}
Pass BuildKonfig.DEBUG or your equivalent. The specific flag name depends on how you expose build config in your project: BuildKonfig from the Gradle plugin, or a plain expect/actual constant, both work fine here.
Content negotiation and JSON
Almost every API returns JSON. The ContentNegotiation plugin handles serialisation and deserialisation for you:
// commonMain/network/HttpClientFactory.kt
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
fun createHttpClient(isDebug: Boolean): HttpClient = HttpClient {
install(Logging) {
logger = Logger.DEFAULT
level = if (isDebug) LogLevel.ALL else LogLevel.NONE
}
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true // don't crash when the API adds a new field you haven't modelled
isLenient = true // tolerates some malformed JSON that real APIs occasionally return
})
}
}
ignoreUnknownKeys = true is the one I always set. Without it, any new field the backend adds to a response will crash your app. Backends evolve faster than mobile release cycles, and one unmodelled field you didn’t know about will catch you in production if you skip this.
Authentication
This is where most projects add technical debt early. The typical approach is to grab the token from wherever it’s stored, format it as a header, and do that in every repository. Every single call. I did this myself for longer than I’d like to admit.
Ktor’s Auth plugin handles this at the client level. You configure how to get the token, and the plugin attaches it to every request:
// commonMain/network/HttpClientFactory.kt
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
fun createHttpClient(
isDebug: Boolean,
tokenStorage: TokenStorage // your own interface for reading/refreshing tokens
): HttpClient = HttpClient {
install(Logging) { /* ... */ }
install(ContentNegotiation) { /* ... */ }
install(Auth) {
bearer {
loadTokens {
// Called before each request — return the current access token
val token = tokenStorage.getAccessToken() ?: return@loadTokens null
BearerTokens(
accessToken = token.accessToken,
refreshToken = token.refreshToken
)
}
refreshTokens {
// Called automatically when a 401 response is received
val refreshed = tokenStorage.refresh(oldTokens?.refreshToken ?: return@refreshTokens null)
?: return@refreshTokens null
BearerTokens(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken
)
}
sendWithoutRequest { request ->
// Return false for endpoints that don't need auth (login, register)
!request.url.pathSegments.contains("auth")
}
}
}
}
loadTokens runs before every request and provides the current token. refreshTokens runs automatically when a 401 response comes back: Ktor retries the original request with the new token without any code in your repository knowing it happened. sendWithoutRequest lets you exclude endpoints that don’t need authentication, like your login endpoint.
A quick note:
refreshTokensruns on a background thread and must be thread-safe. If multiple requests fail with401simultaneously, the plugin handles the refresh once and queues the retries. You don’t need to debounce this yourself, but yourTokenStorageimplementation must be concurrency-safe. Don’t use a plainvaror a non-thread-safe storage mechanism.
The first time this token refresh worked silently in a running app, I genuinely appreciated how much boilerplate it replaced. No interceptor class, no manual 401 handling scattered across the call sites, no race conditions from parallel refresh attempts.
Default request configuration
For an API where every request shares the same base URL and headers, defaultRequest saves repetition:
// commonMain/network/HttpClientFactory.kt
import io.ktor.client.plugins.defaultrequest.*
import io.ktor.http.*
fun createHttpClient(/* ... */): HttpClient = HttpClient {
/* ... other plugins ... */
defaultRequest {
url("https://jsonplaceholder.typicode.com/")
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
}
}
Now every request made through this client goes to your API base URL, with the correct content type headers, without any of your call sites having to specify them. The repository just says client.get("posts") and the full URL assembles itself.
Retry
Network calls fail. Mobile networks especially fail in ways that are transient: a brief loss of signal, a server-side timeout that resolves in milliseconds, a flaky edge node. The HttpRequestRetry plugin handles these gracefully:
// commonMain/network/HttpClientFactory.kt
import io.ktor.client.plugins.*
fun createHttpClient(/* ... */): HttpClient = HttpClient {
/* ... other plugins ... */
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3) // retry on 5xx responses
retryOnException(
maxRetries = 3,
retryOnTimeout = true // also retry on connect/read timeouts
)
exponentialDelay( // wait 2s, 4s, 8s between attempts
base = 2.0,
maxDelayMs = 10_000
)
}
}
retryOnServerErrors retries on any 5xx response. retryOnException catches network-level failures. exponentialDelay prevents hammering a server that’s already under load.
Two things to be careful about here. First, don’t retry on 4xx responses: those are client errors and retrying them won’t help. A 400 Bad Request won’t become a 200 OK on the third attempt. Second, only retry idempotent requests. A POST to create a resource that succeeds but returns a 503 before the response reaches the client will create the resource on every retry. For non-idempotent calls, be deliberate about what you allow the retry plugin to attempt:
// commonMain/network/HttpClientFactory.kt
install(HttpRequestRetry) {
maxRetries = 3
retryIf { request, response ->
// Only retry GET, HEAD, PUT, DELETE — not POST or PATCH
val safeMethod = request.method in listOf(HttpMethod.Get, HttpMethod.Head, HttpMethod.Put, HttpMethod.Delete)
safeMethod && (response.status.value >= 500)
}
exponentialDelay()
}
Putting it together
The complete factory function:
// commonMain/network/HttpClientFactory.kt
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.defaultrequest.*
import io.ktor.client.plugins.logging.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
fun createHttpClient(
isDebug: Boolean,
tokenStorage: TokenStorage
): HttpClient = HttpClient {
install(Logging) {
logger = Logger.DEFAULT
level = if (isDebug) LogLevel.ALL else LogLevel.NONE
}
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = isDebug
})
}
install(Auth) {
bearer {
loadTokens {
val token = tokenStorage.getAccessToken() ?: return@loadTokens null
BearerTokens(token.accessToken, token.refreshToken)
}
refreshTokens {
val refreshed = tokenStorage.refresh(oldTokens?.refreshToken ?: return@refreshTokens null)
?: return@refreshTokens null
BearerTokens(refreshed.accessToken, refreshed.refreshToken)
}
sendWithoutRequest { request ->
!request.url.pathSegments.contains("auth")
}
}
}
defaultRequest {
url("https://jsonplaceholder.typicode.com/")
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
}
install(HttpRequestRetry) {
maxRetries = 3
retryIf { request, response ->
val safeMethod = request.method in listOf(HttpMethod.Get, HttpMethod.Head, HttpMethod.Put, HttpMethod.Delete)
safeMethod && response.status.value >= 500
}
retryOnException(maxRetries = 3, retryOnTimeout = true)
exponentialDelay(base = 2.0, maxDelayMs = 10_000)
}
}
This is the configuration I end up with in most projects after the first real API integration. Not every project needs every plugin, but these are the ones that come up every time.
The crew at work
The demo for this article is a Compose Multiplatform app: one shared module, shared UI running on both Android and iOS.
The client gets created directly in the composable and recreated whenever the debug toggle changes:
// shared/src/commonMain/kotlin/.../App.kt
@Composable
fun App() {
var isDebug by remember { mutableStateOf(true) }
val client = remember(isDebug) {
createHttpClient(isDebug = isDebug, tokenStorage = FakeTokenStorage())
}
val repository = remember(client) { PostsRepository(client) }
// ...
}
The debug toggle is the most useful thing in the app for actually seeing the plugins work. When debug mode is on, LogLevel.ALL fires on every request and you can watch the Authorization header, the full request URL, and the JSON response body in Logcat. Toggle it off and the logger goes silent. Same client, same pipeline, different verbosity.
The repository stays clean:
// shared/src/commonMain/kotlin/.../network/PostsRepository.kt
class PostsRepository(private val client: HttpClient) {
suspend fun getPosts(limit: Int = 10): List<Post> =
client.get("posts") {
parameter("_limit", limit)
}.body()
}
The URL is relative. DefaultRequest supplies the base. Auth attaches the token. ContentNegotiation deserialises the JSON. The repository doesn’t know any of that is happening.
For the demo I’m using JSONPlaceholder, which doesn’t validate auth headers, so the bearer token never gets challenged. But loadTokens still runs on every request, and you can confirm in Logcat that the Authorization: Bearer header goes out on every call. The refresh lambda is wired up and ready. The crew does their lap whether or not the race director calls a stop.
Gotchas
1. Plugin order matters
Ktor processes plugins in the order they’re installed for outgoing requests, and in reverse order for incoming responses. If your Auth plugin depends on ContentNegotiation being set up (for example, your refresh endpoint returns JSON), install ContentNegotiation before Auth.
2. The client must be closed
HttpClient holds resources: threads, connection pools, socket handles. When you’re done with it, call client.close(). A singleton client lives for the lifetime of the app and won’t need manual closing, but scoped clients in tests or background tasks must be closed.
3. Don’t share an HttpClient across different token contexts
If your app has multiple accounts or multiple API backends with different auth, create separate clients for each. Sharing one client across different auth configurations leads to tokens from one context being sent to another endpoint. I’ve seen this cause confusing bugs where users end up seeing data that doesn’t belong to them.
4. refreshTokens can be called from any coroutine context
The refresh lambda runs on whatever thread Ktor chooses. If your TokenStorage does I/O (DataStore, database), make sure the coroutine context handling inside it is correct. Using withContext(Dispatchers.IO) inside the storage implementation is the safe default.
5. retryOnTimeout = true can mask real problems
Retry on timeout is useful for mobile network transience, but a consistently timing-out endpoint is a server-side problem, not a network hiccup. If your timeout retries are firing regularly, fix the cause rather than tuning the retry count upward.
Wrapping up
Ktor’s plugin system moves cross-cutting concerns out of your business logic and into the client configuration. Auth token handling, request logging, content negotiation, and retry behaviour all belong at the pipeline level, not scattered across your repositories and data sources.
Configure one client, share it as a singleton, and let the pit crew handle what happens on every lap.
Once the crew knows their roles, the driver can focus on the race. Set up your client properly at the start, and every call after that just works. 🏁
The full demo for this article is be available on GitHub.
The KMP Bits app is available on App Store and Google Play, built entirely with KMP.
Comments
Loading comments...