Engineering

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.

MobileByteSensei 9 min read
kmpnetworkingktor

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.

One shared HttpTimeout block fanning out to four engines: requestTimeoutMillis is enforced by Ktor itself and means the same thing everywhere, while connectTimeoutMillis is a real TCP bound on OkHttp, a session-policy hint on Darwin, honoured by CIO, and silently ignored by the JS engine

The rule underneath:

A config value is only portable if the layer that defines it is the layer that enforces it. requestTimeoutMillis is 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.

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".

Follow by RSS