One Ktor client, four engines, and the timeouts that don't mean the same thing
You configure one connect timeout. OkHttp, NSURLSession and the browser each enforce a different thing with it.
TL;DR
HttpTimeout is common configuration. Its enforcement is not — it is delegated to whichever
engine the target compiles with, and the four engines do not agree.
connectTimeoutMillis on OkHttp bounds the TCP connect. On Darwin it maps onto a
NSURLSessionConfiguration field that behaves differently. On the JS engine there is no connect
timeout at all — the browser owns connection setup and does not expose it. So the same number,
set once in shared code, is a hard bound on one platform, an approximation on another, and
decorative on a third.
Configure the engine where the engine's semantics live. Keep in common only what every engine can actually honour.
The case this post is about
A sync client for an offline-first app. One Ktor setup in commonMain, four targets — Android,
iOS, desktop, wasmJs. The configuration everyone writes:
// commonMain
fun httpClient(engine: HttpClientEngine) = HttpClient(engine) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(HttpTimeout) {
connectTimeoutMillis = 5_000
requestTimeoutMillis = 20_000
socketTimeoutMillis = 10_000
}
install(HttpRequestRetry) { retryOnServerErrors(maxRetries = 3); exponentialDelay() }
}
// androidMain → OkHttp iosMain → Darwin
// jvmMain → CIO wasmJsMain → Js
actual fun platformEngine(): HttpClientEngine = OkHttp.create()
One config, four engines, no warnings. The sync layer was written against the assumption that a request either completes within 20 seconds or throws. That assumption holds on two of the four.
The symptom
A sync that hangs. On wasm, in a captive-portal-style network — connected, DNS resolves, nothing answers.
sync started t=0
… no completion, no exception …
sync still running t=48s
sync still running t=3m12s
requestTimeoutMillis = 20_000 did not fire. Neither did the retry policy, because there was no
failure to retry — the request had not failed, it had not done anything.
Meanwhile iOS produced the opposite symptom on the same network: it failed, but at a time nobody configured.
iOS: NSURLErrorTimedOut after ~75s (configured: 5s connect / 20s request)
Two platforms, two wrong durations, one shared number. And on Android and desktop the behaviour was exactly as written — which is what makes this hard to notice in development, because that is where you run.
Why the obvious fix failed
"Lower requestTimeoutMillis." Does nothing on wasm for the hanging case: the plugin's
request timeout races a coroutine against a response, and that mechanism works — but a browser
fetch stuck in connection setup was, in our case, not yet inside the window the plugin was
measuring. Lowering a timeout that is measuring the wrong interval just lowers the wrong number.
"Add a withTimeout around the call site." This does work, and I shipped it as a stopgap. But
it is a second timeout layer with no knowledge of the first, so you now have two bounds that can
disagree, and a cancellation that may or may not release the underlying connection. A workaround
at the call site is a sign the layer below has the wrong contract.
"Set the Darwin timeouts to match." Correct instinct, wrong place. I tried matching the
numbers in the common HttpTimeout block, which is exactly where they do not reach — the Darwin
engine's behaviour comes from its NSURLSessionConfiguration, and the common plugin cannot write
that.
"Use one engine everywhere." There is no engine that compiles on all four targets. CIO is close and does not run on wasm. The multi-engine reality is not a choice you can opt out of.
The actual mechanism
HttpTimeout is a plugin, and a plugin can only do two things: wrap the call in its own
timing, or hand values down to the engine. Which one happens depends on the engine, and the
split is where the surprise lives.
requestTimeoutMillis is enforced by Ktor itself. It is a coroutine race around the whole
call, so it is the one value that means the same thing on every engine. This is the honest knob.
connectTimeoutMillis and socketTimeoutMillis are handed to the engine, and the engine
decides. OkHttp maps them onto its own connectTimeout/readTimeout — a genuine TCP-level
bound. Darwin maps onto NSURLSession fields whose semantics are the session's, including its own
retry-and-backoff behaviour that can stretch the observed failure far past the number you set. The
JS engine has no connect notion to configure — the browser owns it, and nothing in the Ktor
API will tell you the value you passed went nowhere.

The rule underneath:
A config value is only portable if the layer that defines it is the layer that enforces it.
requestTimeoutMillisis Ktor's own, so it travels. Connect and socket timeouts belong to the engine, so they must be set per engine — and one engine cannot honour them at all.
The same split explains the retry plugin. HttpRequestRetry retries on exceptions and status
codes. A request that hangs produces neither, so retry is not a safety net for this class of
failure — it is a policy for failures that arrive.
So the configuration splits in two: the bound that must hold everywhere, set once in common; and the engine hints, set per platform by code that is allowed to know it cannot deliver them.
// commonMain — the guarantee. Ktor enforces this one, so it is the same on all four.
fun HttpClientConfig<*>.timeouts(budget: Duration) {
install(HttpTimeout) { requestTimeoutMillis = budget.inWholeMilliseconds }
platformTimeouts(budget)
}
/** Per-engine hints. Returns the knobs it could NOT honour, rather than failing silently. */
expect fun HttpClientConfig<*>.platformTimeouts(budget: Duration): Set<String>
// androidMain — OkHttp: real TCP-level bounds, both honoured
actual fun HttpClientConfig<*>.platformTimeouts(budget: Duration): Set<String> {
engine { this as OkHttpConfig
config { connectTimeout(budget / 3); readTimeout(budget) } }
return emptySet()
}
// jsMain — the browser owns connection setup. Say so; do not accept the value and drop it.
actual fun HttpClientConfig<*>.platformTimeouts(budget: Duration): Set<String> =
setOf("connectTimeoutMillis", "socketTimeoutMillis")
// commonTest — the unhonoured set is asserted, not discovered in production
@Test fun requestBudgetHoldsOnEveryEngine() = runTest {
val unsupported = client.config { timeouts(5.seconds) }
assertTrue("requestTimeoutMillis" !in unsupported) // the guarantee, everywhere
}
On the named case — the 90-second hang that started this:
engine requestTimeout observed failure unhonoured
OkHttp 5s 5.0s -
Darwin 5s 5.1s -
CIO 5s 5.0s -
JS 5s 5.0s connectTimeoutMillis, socketTimeoutMillis
The JS row is the point. The connect timeout is still not enforceable there — what changed is that the code says so and a test asserts it, instead of a config block implying a bound that never existed.
The fix
Split the configuration by who enforces it. Common keeps what Ktor owns; the engine factory sets what the engine owns.
// commonMain — only what every engine honours identically
fun httpClient(engine: HttpClientEngine) = HttpClient(engine) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(HttpTimeout) {
requestTimeoutMillis = 20_000 // Ktor's own race — portable
}
install(HttpRequestRetry) { retryOnServerErrors(maxRetries = 3); exponentialDelay() }
}
// androidMain — connect/read are real, so set them here
actual fun platformEngine(): HttpClientEngine = OkHttp.create {
config {
connectTimeout(5, TimeUnit.SECONDS)
readTimeout(10, TimeUnit.SECONDS)
}
}
// iosMain — the session owns the policy; say so explicitly
actual fun platformEngine(): HttpClientEngine = Darwin.create {
configureSession {
timeoutIntervalForRequest = 20.0
timeoutIntervalForResource = 30.0 // the real upper bound on Darwin
waitsForConnectivity = false // otherwise it waits, by design
}
}
// wasmJsMain — no connect timeout exists. requestTimeoutMillis is the only bound.
actual fun platformEngine(): HttpClientEngine = Js.create()
Two details carry most of the value.
waitsForConnectivity = false is the iOS 75-second answer. Left at its default, NSURLSession
will sit and wait for a usable network rather than fail — which is the right default for a
background upload and the wrong one for a foreground sync. The duration nobody configured was a
documented policy nobody disabled.
wasm gets an explicit comment rather than a value. Writing connectTimeoutMillis there would
look like coverage and provide none. The absence is now visible in the code, which is the only
place a future reader will look.
Then the sync layer stops assuming a universal bound and asks for one it can trust:
// commonMain — one bound the platform cannot weaken
suspend fun <T> bounded(block: suspend () -> T): T =
withTimeout(25_000) { block() } // > requestTimeout, < any engine's ceiling
This is the same withTimeout I called a workaround earlier, and it is now something different:
not a patch for a timeout that failed to fire, but a deliberate outer bound for the one platform
where no engine-level bound exists. The difference is that it is chosen, documented, and
ordered relative to the inner timeout rather than racing it blindly.
How we proved it
The measurement that mattered was per-engine, because the whole claim is that the engines differ.
A dead listener — a socket that accepts and never responds — gives a reproducible hang. Same config, same URL, four targets, time to failure recorded:
configured observed
OkHttp (Android) 5s connect 5.1s ← engine enforced it
CIO (desktop) 5s connect 5.0s ← engine enforced it
Darwin (iOS) 5s connect 74.8s ← session policy, waitsForConnectivity
Js (wasm) 5s connect never ← value went nowhere
That table is the post. Nothing about it is visible from commonMain, and no compiler or lint
will produce it for you.
After the split:
OkHttp 5.1s Darwin 20.3s Js 20.1s (requestTimeout) CIO 5.0s
Every platform now fails inside a bound someone chose.
One honest gap. I verified Darwin on a device against a dead listener, which is one of several
network pathologies; waitsForConnectivity also interacts with the system's own connectivity
state in ways a single test does not fully characterise. The 75-second figure is what we measured,
not a documented constant — treat it as evidence that the policy exists, and measure your own.
What this generalizes to
Shared configuration is only shared if one layer enforces it. The useful question about any cross-platform config value is not "does it compile everywhere" but "who reads it". Values the abstraction enforces itself are portable. Values it forwards are a per-backend contract wearing one name.
A silently ignored setting is worse than an unsupported one. connectTimeoutMillis on the JS
engine accepts your number and discards it. A compile error, or even a runtime warning, would
have cost minutes. Where you build an abstraction over heterogeneous backends, make the unsupported
case loud.
Defaults encode someone else's use case. waitsForConnectivity is correct for a resumable
background transfer and wrong for an interactive sync. Platform defaults are chosen for the
platform's most common job, which is rarely yours — reading them is part of adopting the platform.
Retry does not cover hangs. Retry policies trigger on arrival of a failure. A request that never arrives at any outcome is outside their domain, and a timeout is the only thing that can end it. Check that your fallback actually fires for the failure mode you are worried about.
Related
- Your shared database needs a driver per platform, and that is the easy half — the same hidden-property split, for storage
- expect/actual is not an interface, and the difference shows up at link time — compile-time vs runtime resolution
- 429 is not an error, it is a schedule — when retrying IS the right answer
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".