Part 1 of 4 — This series walks through a complete production networking architecture for Kotlin Multiplatform using Ktor.
There's a moment in every KMP project where things start feeling real. You've got your shared module wired up, your expect/actual declarations are in place, and now you need to make your first API call. So you add Ktor, write a quick HttpClient, make a request, and it works.
Then the app grows.
Suddenly you're dealing with token expiration on iOS, retry logic that keeps firing on 401s, WebSocket connections that need to stay alive across screens, and a team of developers who keep writing the same loading/error/success handling in slightly different ways across 20 different screens.
That's the problem this series exists to solve.
Over the next four parts, I'll walk you through the exact networking architecture we use in a production KMP application — one that runs on both Android and iOS, handles real users, and has been through enough edge cases to earn some opinions. We'll go from Ktor setup all the way through a request state pattern that makes your ViewModels and Composables a pleasure to work with, and through pagination and live WebSocket connections.
This first part covers the foundation: setting up Ktor in a KMP project correctly, and building an HttpClient that's ready for production from day one.
Why Ktor, and Why the Setup Matters
If you're coming from Android-only development, you probably lived on Retrofit + OkHttp. It's a great stack. But in KMP, Retrofit doesn't work in shared code. You need something that runs in commonMain, and Ktor is the answer.
Ktor is JetBrains' own async HTTP client, built on coroutines, and designed from the ground up for multiplatform. It uses platform-native engines under the hood — OkHttp on Android, Darwin (URLSession) on iOS — so you get native performance without writing platform-specific networking code. Everything you configure lives in commonMain.
But the way most people set up Ktor for the first time is far too simple for production. A single HttpClient() call with ContentNegotiation installed gets you making requests, but it doesn't get you:
- Automatic token refresh that doesn't race condition itself
- Smart retry logic that knows the difference between a server error and a 401
- Consistent headers across every request
- Logging that only runs in debug builds
- WebSocket support with proper ping intervals
Let's build it properly.
Dependencies
Here's what you'll need in your libs.versions.toml. At the time of writing, Ktor 3.x is the current stable line:
[versions]
ktor = "3.4.0"
[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-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-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
And in your build.gradle.kts shared module:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.websockets)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
The key insight here: ktor-client-core and all the plugins live in commonMain. The engine — OkHttp for Android, Darwin for iOS — is the only platform-specific piece. Your entire networking configuration, authentication logic, retry policies, and serialization happen once, in shared code.
The JSON Configuration
Before building the client, let's nail the JSON parser. This is one of those things that seems trivial until you're debugging a crash at 2am because an API returned an unexpected field.
object JsonConfig {
val parser = Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
useAlternativeNames = true
}
}
ignoreUnknownKeys = true is non-negotiable. APIs evolve. The backend team will add new fields to responses, and you don't want your app crashing on old builds just because the server returned a field you haven't modelled yet. isLenient = true lets you handle slight JSON variations — empty strings where nulls might be expected, and similar real-world messiness. useAlternativeNames respects @JsonNames annotations, which is useful when you need to map multiple possible field names to a single property.
This JsonConfig object gets passed directly to the ContentNegotiation plugin later.
Building the HttpClient
Here's the full client factory, which we keep in an object called HttpNetworkClient:
object HttpNetworkClient {
private const val BASE_URL = "https://yourdomain.com/api/"
const val WSS_URL = "wss://yourdomain.com/"
fun createHttpClient(
authStateStore: AuthStateStore,
sessionStateStore: SessionStateStore,
appLocalizationRepository: AppLanguageStateStore,
platformDetail: PlatformDetail,
platformSpecific: PlatformSpecific
): HttpClient {
return HttpClient {
defaultRequest {
url(BASE_URL)
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
headers.append("Device-Type", platformDetail.getPlatformName())
headers.append("Device-Id", platformSpecific.getUniqueDeviceId() ?: "")
headers.append("App-Version", platformDetail.getAppVersionName())
headers.append(
"Accept-Language",
appLocalizationRepository.languagePreference.value.serverCode
)
val userAccessToken = authStateStore.userTokenData.value?.accessToken
if (!userAccessToken.isNullOrBlank()) {
bearerAuth(userAccessToken)
} else {
headers.remove(HttpHeaders.Authorization)
}
}
install(ContentNegotiation) {
json(json = JsonConfig.parser)
}
install(WebSockets) {
pingInterval = 5000.milliseconds
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000L
connectTimeoutMillis = 30_000L
socketTimeoutMillis = 30_000L
}
install(Auth) {
bearer {
refreshTokens {
return@refreshTokens try {
val newAccessToken = refreshBearerToken(authStateStore)
if (!newAccessToken.isNullOrBlank()) {
val currentRefreshToken =
authStateStore.userTokenData.value?.refreshToken
BearerTokens(
accessToken = newAccessToken,
refreshToken = currentRefreshToken.orEmpty()
)
} else {
sessionStateStore.onSessionExpired()
null
}
} catch (ex: Exception) {
sessionStateStore.onSessionExpired()
null
}
}
}
}
if (isDebugBuild()) {
install(Logging) {
logger = object : io.ktor.client.plugins.logging.Logger {
override fun log(message: String) {
Logger.debug(message = message, tag = "NetworkClient")
}
}
level = LogLevel.BODY
}
}
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599 ||
response.status == HttpStatusCode.RequestTimeout
}
exponentialDelay()
}
}
}
}
Let's walk through each piece and the reasoning behind it.
defaultRequest — One Place for All Cross-Cutting Concerns
The defaultRequest block is where you set everything that should apply to every single request. The base URL, default content type and accept headers, and any custom headers your backend expects.
Notice the token injection:
val userAccessToken = authStateStore.userTokenData.value?.accessToken
if (!userAccessToken.isNullOrBlank()) {
bearerAuth(userAccessToken)
} else {
headers.remove(HttpHeaders.Authorization)
}
This runs on every request. If there's a token, it gets attached. If there isn't one — because the user isn't logged in — the Authorization header gets explicitly removed. That last part matters. Without it, you can end up in a state where Ktor tries to attach a null or empty token on public endpoints, which causes unexpected behaviour on some backends.
One thing to be aware of: defaultRequest is evaluated at request time, not at client creation time. So the token read here always reflects the current auth state. This is exactly what you want — you never want a stale token baked into the client at construction.
ContentNegotiation — Wiring in Your JSON Parser
install(ContentNegotiation) {
json(json = JsonConfig.parser)
}
This connects your JsonConfig.parser to Ktor's serialization pipeline. Every response body you call .body<T>() on will be deserialized through this parser. Every request body you set with setBody() will be serialized through it too. One configuration, consistent everywhere.
WebSockets — The Ping Interval Is More Important Than It Looks
install(WebSockets) {
pingInterval = 5000.milliseconds
}
If you plan to use WebSockets anywhere in your app — and we'll cover that in depth in Part 4 — you need to install the WebSocket plugin at the client level. The pingInterval of 5 seconds keeps the connection alive by automatically sending ping frames to the server. Without this, connections in mobile environments (with aggressive network management) will time out silently. You'll then spend an afternoon staring at logs wondering why messages stopped arriving.
HttpTimeout — All Three, Always
install(HttpTimeout) {
requestTimeoutMillis = 30_000L
connectTimeoutMillis = 30_000L
socketTimeoutMillis = 30_000L
}
Set all three timeouts explicitly. requestTimeoutMillis is the total time for a request to complete. connectTimeoutMillis is how long to wait when establishing a connection. socketTimeoutMillis covers inactivity on the socket itself — useful for catching a server that accepts a connection but then goes silent.
30 seconds is a reasonable default for most APIs. For file upload or download endpoints, you'll want to override these per-request with a longer timeout.
Auth — Automatic Token Refresh Done Right
This is the most interesting part. Ktor's Auth plugin with bearer configuration handles the token refresh cycle automatically:
install(Auth) {
bearer {
refreshTokens {
return@refreshTokens try {
val newAccessToken = refreshBearerToken(authStateStore)
if (!newAccessToken.isNullOrBlank()) {
val currentRefreshToken =
authStateStore.userTokenData.value?.refreshToken
BearerTokens(
accessToken = newAccessToken,
refreshToken = currentRefreshToken.orEmpty()
)
} else {
sessionStateStore.onSessionExpired()
null
}
} catch (ex: Exception) {
sessionStateStore.onSessionExpired()
null
}
}
}
}
Here's how Ktor's bearer auth flow works: when a request returns a 401 Unauthorized, Ktor automatically invokes refreshTokens. If that callback returns valid BearerTokens, Ktor retries the original request with the new access token — no extra code required. If refreshTokens returns null, Ktor stops retrying. That's your signal to log the user out, which is what sessionStateStore.onSessionExpired() does.
You'll notice we're not using loadTokens here. loadTokens introduces complications when the token gets cleared during logout — the plugin can end up re-attaching a stale cached token to requests even after auth state has been cleared. Instead, we read the token directly in defaultRequest, which always reflects live state.
Important: Ktor's
Authplugin handles its own retry cycle for 401 responses internally — it does not go throughHttpRequestRetry. This is intentional.HttpRequestRetryis for server errors and timeouts.Authhandles 401s. Keep these concerns separate and you avoid a category of subtle infinite-loop bugs where token refresh and exponential backoff interact in unexpected ways.
The actual token refresh logic lives in a separate private function:
private suspend fun refreshBearerToken(authStateStore: AuthStateStore): String? {
val refreshToken = authStateStore.userTokenData.value?.refreshToken ?: return null
return try {
val response = HttpClient {
defaultRequest {
url(BASE_URL)
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
}
install(ContentNegotiation) {
json(json = JsonConfig.parser)
}
}.request {
url("token-refresh/")
method = HttpMethod.Post
contentType(ContentType.Application.Json)
setBody(mapOf("refresh" to refreshToken))
}
if (!response.status.isSuccess()) return null
val jsonBody = response.bodyAsText()
val json = JsonConfig.parser.parseToJsonElement(jsonBody).jsonObject
val newAccessToken = json["access"]?.jsonPrimitive?.content
if (newAccessToken.isNullOrBlank()) return null
updateTokens(newAccessToken, refreshToken, authStateStore)
newAccessToken
} catch (e: Exception) {
null
}
}
Notice we create a fresh, minimal HttpClient inside refreshBearerToken. This is deliberate. If we used the main client to make the refresh request, we'd create a circular dependency — the refresh call itself could trigger another refresh attempt, which would trigger another, and so on. A clean lightweight client with no auth plugin makes the refresh request unconditionally.
Logging — Debug Only, Always
if (isDebugBuild()) {
install(Logging) {
logger = object : io.ktor.client.plugins.logging.Logger {
override fun log(message: String) {
Logger.debug(message = message, tag = "NetworkClient")
}
}
level = LogLevel.BODY
}
}
LogLevel.BODY logs full request and response bodies, invaluable during development. The isDebugBuild() guard ensures this never runs in production. Routing output through your own Logger abstraction keeps logging consistent and makes it easy to swap logging libraries without touching this file.
HttpRequestRetry — Smart Retries for Real Failures
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599 ||
response.status == HttpStatusCode.RequestTimeout
}
exponentialDelay()
}
Three retries, only on 5xx server errors and request timeouts. exponentialDelay() uses the exponential backoff algorithm so retries don't hammer a struggling server. We explicitly do not retry on 4xx errors — those are client errors, retrying them is meaningless. We also do not retry on 401 — that's handled by the Auth plugin separately.
How the Client Gets Injected
The HttpNetworkClient.createHttpClient() factory function takes dependencies as parameters — auth state, session state, localization, platform details. With Koin, the setup looks like this:
single {
HttpNetworkClient.createHttpClient(
authStateStore = get(),
sessionStateStore = get(),
appLocalizationRepository = get(),
platformDetail = get(),
platformSpecific = get()
)
}
The HttpClient instance is registered as a singleton. Every repository gets the same shared client instance — consistent configuration everywhere, no wasted resources from creating multiple client instances.
What We've Built
By the end of this setup, every network request in the app automatically gets:
- The right base URL and content type headers
- A bearer token if the user is logged in, no
Authorizationheader if they're not - Custom headers like
Device-Type,App-Version, andAccept-Language - Automatic token refresh on 401, with session expiry handling if refresh fails
- JSON serialization through a single, lenient, well-configured parser
- 30-second timeouts across the board
- Up to 3 retries with exponential backoff on server errors
- Full request/response body logging in debug builds
- WebSocket support ready to use
None of this requires any code in the repositories, ViewModels, or UI layers. It's declared once, in one place.
What's Next
An HttpClient gets you sending requests. But what happens when the response comes back — what do you actually do with it?
In Part 2, we'll build the ResponseResult sealed class and the safeRequest extension function: a complete, typed error-handling layer that maps every failure mode — network errors, timeouts, auth failures, serialization errors, custom HTTP status codes — into structured, actionable results that your repositories can return cleanly.
The goal is a networking layer where you never write a try-catch in a repository again.
Found this useful? Drop a ❤️ or leave a comment — it helps more developers find the series.
Top comments (0)