Networking with the Ktor Client

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Almost every app talks to a server, and in Kotlin Multiplatform (KMP) the network layer is the most natural thing to share: requests, JSON parsing, authentication and error handling are pure logic, while only the socket-level transport differs between platforms. The Ktor client is JetBrains' multiplatform HTTP client. You write an HttpClient once in commonMain, and each platform plugs in the native engine it prefers: OkHttp on Android, NSURLSession on iOS, and so on. Retrofit, the usual choice on Android, is JVM-only and cannot live in common code; see Android: Networking with Retrofit, OkHttp and Ktor for the Android-only story and for the comparison with Retrofit.

This page targets the section baseline: Kotlin 2.4.20, Ktor 3.6.0, kotlinx.serialization 1.11.0 and kotlinx-coroutines 1.11.0, with Android through the com.android.kotlin.multiplatform.library plugin (AGP 9.4). Stability is stated wherever it is not Stable: the Ktor client plugins used here are Stable, the Js and Wasm engine follow the Kotlin/Wasm and Kotlin/JS status (Wasm is Beta), and Ktor’s WebSocket and Server-Sent-Events clients are Stable but still marked with opt-in annotations on some members. The examples slice the running example, TrailMate Everywhere (package com.example.trailmate), introduced in What Kotlin Multiplatform Is and When to Use It. Kotlin coroutines are not re-taught; see Coroutines, Flow and Concurrency in Shared Code. Gradle setup for dependencies is covered in Dependencies, klibs.io, cinterop and SwiftPM Import.

Why Ktor in Shared Code

Ktor is built from the ground up on Kotlin coroutines and Kotlin Multiplatform, so a request is a suspend call that works unchanged on every target.

Option Where it runs Multiplatform Typical use

Ktor client

JVM, Android, Apple, Linux, Windows, JS, Wasm

Yes, common API with swappable engines

The default for shared networking in KMP

Retrofit + OkHttp

JVM and Android

No (JVM only)

Android-only apps; see the Android section

The trade-off is where the boundary sits: if the shared module owns networking, one client enforces the same authentication, retry and error semantics on every platform, at the cost of iOS developers no longer seeing requests in Swift tooling (Ktor’s logging plugin compensates). See Architecture, Shared ViewModels and State.

Architecture: One Client, Many Engines

An HttpClient has two parts: the client core with its plugins (JSON, auth, retries), which is pure common Kotlin, and an engine that performs the actual I/O. The core artifact ktor-client-core goes in commonMain; engine artifacts go in platform source sets. When you call HttpClient() without an argument in common code, Ktor picks the engine found on the classpath of that target, which is why one engine dependency per target is enough.

flowchart TB subgraph common["commonMain (sharedLogic)"] API["TrailApiClient"] CORE["HttpClient + plugins
ContentNegotiation, Auth, Retry, Logging"] API --> CORE end CORE -->|"androidMain"| OK["OkHttp engine"] CORE -->|"iosMain / appleMain"| DA["Darwin engine
(NSURLSession)"] CORE -->|"jvmMain (desktop)"| CIO["CIO or Java engine"] CORE -->|"wasmJsMain / jsMain"| JS["Js engine
(fetch)"]

Engines per Platform

Target Engine artifact Notes Choose it when

Android

ktor-client-okhttp

Uses OkHttp, with its interceptors, connection pool and HTTP/2

You already use OkHttp, or want its ecosystem (certificate pinning, interceptors)

JVM (desktop)

ktor-client-java

Uses the JDK java.net.http.HttpClient; HTTP/2 supported

Desktop or server on Java 11+ without extra libraries

iOS, macOS, tvOS, watchOS

ktor-client-darwin

Uses NSURLSession; honours the system proxy, certificates and App Transport Security

Always on Apple targets (the recommended engine)

Kotlin/JS, Kotlin/Wasm

ktor-client-js

Uses the browser fetch API (or Node’s equivalent)

Browser targets; subject to CORS

On Apple targets prefer ktor-client-darwin, because it integrates with system-level behaviour such as VPN, proxy settings and certificate trust that a pure-Kotlin engine would bypass. ktor-client-cio is the pure-Kotlin alternative on Android and JVM (no HTTP/2) and also runs on Linux and Windows native targets, and ktor-client-curl is the alternative on Linux and Windows desktop.

Declaring the Dependencies

The API goes in commonMain and each engine in its own source set. With the Android-KMP library plugin the Android source set is androidMain.

// sharedLogic/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
    alias(libs.plugins.kotlinSerialization)               // org.jetbrains.kotlin.plugin.serialization
}

kotlin {
    android { namespace = "com.example.trailmate.shared" }
    jvm()
    iosArm64()
    iosSimulatorArm64()
    wasmJs { browser() }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.content.negotiation)
            implementation(libs.ktor.serialization.kotlinx.json)
            implementation(libs.ktor.client.logging)
            implementation(libs.ktor.client.auth)
        }
        androidMain.dependencies { implementation(libs.ktor.client.okhttp) }
        jvmMain.dependencies { implementation(libs.ktor.client.java) }
        iosMain.dependencies { implementation(libs.ktor.client.darwin) }
        wasmJsMain.dependencies { implementation(libs.ktor.client.js) }
        commonTest.dependencies { implementation(libs.ktor.client.mock) }
    }
}

All Ktor artifacts share one version (ktor = "3.6.0" in the version catalog), so engines and plugins never drift apart. The iosMain source set is shared by iosArm64 and iosSimulatorArm64 through the default hierarchy template; see Project Structure, Targets, Source Sets and the Hierarchy Template. See Ktor — Create a cross-platform mobile application and Ktor — Client engines.

Providing the Engine: Default Discovery or an expect/actual Factory

For most apps HttpClient { …​ } in common code is enough, because Ktor discovers the single engine on each target. When one target needs engine-specific configuration (an OkHttp interceptor, a Darwin cellular setting), use the expect/actual pattern from expect/actual and Calling Platform APIs, or, cleaner, an interface injected by Koin. Here the engine factory is expect/actual:

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/HttpEngineFactory.kt
package com.example.trailmate.data.remote

import io.ktor.client.engine.HttpClientEngine

expect fun httpClientEngine(): HttpClientEngine
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/data/remote/HttpEngineFactory.android.kt
package com.example.trailmate.data.remote

import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
import java.util.concurrent.TimeUnit

actual fun httpClientEngine(): HttpClientEngine = OkHttp.create {
    config {
        // OkHttp-only tuning that has no common equivalent
        retryOnConnectionFailure(true)
        pingInterval(30, TimeUnit.SECONDS)
    }
}
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/data/remote/HttpEngineFactory.ios.kt
package com.example.trailmate.data.remote

import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.darwin.Darwin

actual fun httpClientEngine(): HttpClientEngine = Darwin.create {
    configureRequest {
        // NSMutableURLRequest tuning: allow cellular access
        setAllowsCellularAccess(true)
    }
}

The JVM and Wasm actuals are one-liners, Java.create() and Js.create(), in jvmMain and wasmJsMain.

Passing the engine as a parameter (HttpClient(engine) { …​ }) is also what makes tests easy: the test swaps in a MockEngine. See Ktor — Client engines for every engine and its options.

Creating the HttpClient

An HttpClient is heavyweight: it owns a connection pool and threads. Create one per app and reuse it, closing it only when the app shuts down. Creating a client per request is the most common performance bug.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/HttpClientFactory.kt
package com.example.trailmate.data.remote

import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

object HttpClientFactory {
    fun create(engine: HttpClientEngine): HttpClient = HttpClient(engine) {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true; explicitNulls = false })
        }
        install(HttpTimeout) {
            connectTimeoutMillis = 10_000
            requestTimeoutMillis = 30_000
            socketTimeoutMillis = 30_000
        }
        install(Logging) {
            logger = Logger.SIMPLE
            level = LogLevel.INFO
        }
        defaultRequest {
            url("https://api.example.com/v1/")
            contentType(ContentType.Application.Json)
        }
        expectSuccess = true
    }
}

Each plugin is explained in the sections below. See Ktor — Create and configure a client.

JSON with ContentNegotiation and kotlinx.serialization

ContentNegotiation converts request bodies to JSON and response bodies back to Kotlin objects. It needs a serializer; on KMP that is ktor-serialization-kotlinx-json, which depends on kotlinx.serialization and works on every target (Gson and Jackson do not, because they are JVM-only). The model classes carry @Serializable, which is generated by the compiler plugin org.jetbrains.kotlin.plugin.serialization.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/TrailDto.kt
package com.example.trailmate.data.remote

import com.example.trailmate.domain.Difficulty
import com.example.trailmate.domain.Trail
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class TrailDto(
    val id: String,
    val name: String,
    val region: String,
    @SerialName("length_km") val lengthKm: Double,
    val difficulty: String,
    @SerialName("photo_url") val photoUrl: String? = null,
)

@Serializable
data class TrailPageDto(val items: List<TrailDto>, val page: Int, val hasMore: Boolean)

fun TrailDto.toDomain() = Trail(id, name, region, lengthKm, Difficulty.valueOf(difficulty.uppercase()), photoUrl, isFavorite = false)

DTOs (data transfer objects) are kept separate from the domain Trail so that a server rename never ripples into the UI. ignoreUnknownKeys = true keeps old app versions working when the server adds fields, a property that matters because mobile users update slowly. See Ktor — Content negotiation and serialization in the client and Kotlin docs — Serialization.

TrailApiClient

The API client is a plain class that takes the HttpClient through its constructor. This dependency injection (DI) keeps it testable and lets Koin supply the shared client.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/TrailApiClient.kt
package com.example.trailmate.data.remote

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter

interface TrailApi {
    suspend fun trails(page: Int, region: String?): TrailPageDto
    suspend fun trail(id: String): TrailDto
}

class TrailApiClient(private val client: HttpClient) : TrailApi {

    override suspend fun trails(page: Int, region: String?): TrailPageDto =
        client.get("trails") {
            parameter("page", page)
            region?.let { parameter("region", it) }
        }.body()

    override suspend fun trail(id: String): TrailDto =
        client.get("trails/$id").body()
}

Three details are worth noticing. The relative path "trails" is resolved against the defaultRequest base URL https://api.example.com/v1/ (the trailing slash matters, because without it /v1 is dropped; it makes the resolution append rather than replace the last segment). parameter() URL-encodes values, so never concatenate query strings by hand. And .body() is a reified function that deserializes using the installed ContentNegotiation. See Ktor — Making requests and Ktor — Receiving responses.

defaultRequest

defaultRequest { } applies settings to every request the client makes: the base URL, shared headers and the content type. It removes repeated boilerplate and gives one place to change the API host between environments.

The block above already sets the base URL and Content-Type; shared headers such as header("X-Client", "trailmate-kmp") and Accept-Language go in the same block, and url { protocol = URLProtocol.HTTPS; host = "api.example.com" } is the structured alternative to a string URL.

A request can still override any default (for instance to call a different host for an image CDN). If the base URL comes from build configuration, pass it into HttpClientFactory.create(baseUrl, engine) rather than reading globals. See Ktor — Default request.

Timeouts

Without HttpTimeout, behaviour depends on each engine’s defaults, which differ between OkHttp, NSURLSession and fetch. Installing the plugin gives one consistent policy.

The plugin has three limits: connectTimeoutMillis (establishing the connection, typically 10 s), requestTimeoutMillis (the whole call, typically 30 s) and socketTimeoutMillis (maximum silence between packets, typically 30 s).

A timeout throws HttpRequestTimeoutException, ConnectTimeoutException or SocketTimeoutException. The plugin is Stable on all targets, but note that on the Js engine the connect and socket timeouts are not supported by the browser fetch API, so only requestTimeoutMillis applies there. Timeouts can also be set for a single request with timeout { requestTimeoutMillis = 120_000 }, which is the right tool for large downloads. See Ktor — Timeout.

Retries

HttpRequestRetry re-sends requests that failed because of a server error or an exception. Retrying blindly is dangerous (a repeated POST can create duplicates), so decide per request.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/HttpClientFactory.kt (excerpt)
import io.ktor.client.plugins.HttpRequestRetry

install(HttpRequestRetry) {
    retryOnServerErrors(maxRetries = 3)
    retryOnException(maxRetries = 3, retryOnTimeout = true)
    exponentialDelay(base = 2.0, maxDelayMs = 10_000)
}

exponentialDelay waits longer after each failure, with jitter, so many clients do not retry in lockstep against a recovering server. A single request opts out with retry { noRetry() }. Retries multiply the total wall-clock time, so size requestTimeoutMillis accordingly or the caller waits minutes before seeing an error. See Ktor — Retrying failed requests.

Logging

The Logging plugin prints requests and responses. Logger.SIMPLE prints to the platform’s standard output, which on iOS shows up in the Xcode console. A custom Logger forwards lines to a logging library such as Kermit.

A custom logger is logger = object : Logger { override fun log(message: String) = AppLog.d("Http", message) }, and sanitizeHeader { it == HttpHeaders.Authorization } redacts the token from the output.

Levels run from ALL to NONE. Never log bodies or tokens in release builds; use sanitizeHeader to redact Authorization. See Ktor — Logging.

Authentication with Bearer Tokens and Refresh

The Auth plugin with the bearer provider adds an Authorization: Bearer <token> header and, when the server answers 401 Unauthorized, calls refreshTokens once and replays the request. This logic is identical on every platform, which is a strong argument to keep it in common code.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/AuthInstaller.kt
package com.example.trailmate.data.remote

import io.ktor.client.HttpClientConfig
import io.ktor.client.call.body
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.serialization.Serializable

interface TokenStore {
    suspend fun load(): BearerTokens?
    suspend fun save(tokens: BearerTokens)
}

@Serializable
private data class RefreshRequest(val refreshToken: String)

@Serializable
private data class TokenResponse(val accessToken: String, val refreshToken: String)

fun HttpClientConfig<*>.installAuth(store: TokenStore) {
    install(Auth) {
        bearer {
            loadTokens { store.load() }
            refreshTokens {
                val old = oldTokens ?: return@refreshTokens null
                val fresh: TokenResponse = client.post("auth/refresh") {
                    markAsRefreshTokenRequest()
                    setBody(RefreshRequest(old.refreshToken))
                }.body()
                BearerTokens(fresh.accessToken, fresh.refreshToken).also { store.save(it) }
            }
            sendWithoutRequest { request -> request.url.host == "api.example.com" }
        }
    }
}

markAsRefreshTokenRequest() tells the plugin that this call must not itself trigger a refresh, which avoids an infinite loop. sendWithoutRequest limits the header to your own host, so the token is not leaked to third-party URLs such as image CDNs. Store tokens in the platform keystore (Android Keystore, iOS Keychain) behind TokenStore, never in plain preferences; the tokens in this snippet are placeholders supplied at runtime. Ktor caches the loaded tokens in memory until a refresh happens, so call the clearToken() extension of the provider on logout. See Ktor — Bearer authentication.

Status Codes and Error Handling

By default Ktor does not throw for 4xx and 5xx responses; client.get(…​) returns a response and .body() fails later with a confusing deserialization error. Setting expectSuccess = true turns non-2xx statuses into exceptions:

The exceptions are RedirectResponseException (3xx), ClientRequestException (4xx), ServerResponseException (5xx), all subclasses of ResponseException, plus HttpRequestTimeoutException and platform-specific I/O errors for a network that is down.

Because engine exceptions differ per platform, wrap them in your own sealed error type at the data boundary so that the ViewModel handles one hierarchy.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/ApiResult.kt
package com.example.trailmate.data.remote

import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.CancellationException

sealed interface ApiError {
    data object Network : ApiError
    data object Timeout : ApiError
    data object Unauthorized : ApiError
    data class Http(val status: Int) : ApiError
    data class Unknown(val cause: Throwable) : ApiError
}

suspend fun <T> apiCall(block: suspend () -> T): Result<T> = try {
    Result.success(block())
} catch (e: CancellationException) {
    throw e // never swallow cancellation
} catch (e: ClientRequestException) {
    Result.failure(ApiException(
        if (e.response.status == HttpStatusCode.Unauthorized) ApiError.Unauthorized
        else ApiError.Http(e.response.status.value)))
} catch (e: ServerResponseException) {
    Result.failure(ApiException(ApiError.Http(e.response.status.value)))
} catch (e: HttpRequestTimeoutException) {
    Result.failure(ApiException(ApiError.Timeout))
} catch (e: Throwable) {
    Result.failure(ApiException(ApiError.Network))
}

class ApiException(val error: ApiError) : Exception()

Rethrowing CancellationException is essential: swallowing it breaks structured concurrency, so a cancelled screen would keep running work. For server-defined error bodies, install a HttpResponseValidator { handleResponseExceptionWithRequest { …​ } } that reads the JSON error payload once for the whole client. See Ktor — Response validation.

WebSockets

For real-time features such as live trail conditions, Ktor provides a WebSocket client. Install the plugin and open a session; frames arrive as a coroutine-friendly stream.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/remote/ConditionsSocket.kt
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText

fun conditions(client: HttpClient, trailId: String): Flow<String> = flow {
    client.webSocket("wss://api.example.com/v1/trails/$trailId/conditions") {
        for (frame in incoming) if (frame is Frame.Text) emit(frame.readText())
    }
}

This requires install(WebSockets) and the ktor-client-websockets artifact. Support varies by engine: OkHttp, Darwin, CIO, Java and Js support it, but the Darwin engine exposes WebSockets through NSURLSession and does not accept every extension. Server-Sent Events (SSE) is a simpler alternative for one-way streams. See Ktor — WebSockets in the client.

Testing with MockEngine

Because the engine is injected, tests replace it with MockEngine, which answers from a lambda without any network. This is also how FakeTrailApiClient is built. Tests run in commonTest, so the same suite executes on JVM, iOS simulator and the other targets.

// sharedLogic/src/commonTest/kotlin/com/example/trailmate/data/remote/FakeTrailApiClient.kt
package com.example.trailmate.data.remote

import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf

fun fakeTrailApiClient(
    status: HttpStatusCode = HttpStatusCode.OK,
    body: String = TRAILS_JSON,
): TrailApiClient {
    val engine = MockEngine { request ->
        check(request.url.encodedPath.endsWith("/trails")) { "Unexpected ${request.url}" }
        respond(
            content = body,
            status = status,
            headers = headersOf(HttpHeaders.ContentType, "application/json"),
        )
    }
    return TrailApiClient(HttpClientFactory.create(engine))
}

private const val TRAILS_JSON =
    """{"items":[{"id":"t1","name":"Ridge Loop","region":"Alps","length_km":8.4,"difficulty":"moderate"}],"page":1,"hasMore":false}"""
// sharedLogic/src/commonTest/kotlin/com/example/trailmate/data/remote/TrailApiClientTest.kt
package com.example.trailmate.data.remote

import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals

class TrailApiClientTest {
    @Test
    fun parsesTrailPage() = runTest {
        val page = fakeTrailApiClient().trails(page = 1, region = null)
        assertEquals("Ridge Loop", page.items.single().name)
    }
}

Using the real HttpClientFactory with a MockEngine tests the configured client (serialization, base URL, expectSuccess) and not just your code. The mock engine is Stable and is the recommended approach; Mokkery-style mocks of HttpClient are fragile because HttpClient is a large concrete class. See Ktor — Testing and Testing Multiplatform Code.

Wiring It with Koin

The client is a Koin singleton, single { HttpClientFactory.create(httpClientEngine()) }, and TrailApiClient is bound to TrailApi with single<TrailApi> { TrailApiClient(get()) } inside trailMateModule; see Dependency Injection in KMP: Koin, Metro and Manual DI and Koin — Koin for Kotlin Multiplatform.

Using the Client from Swift

The iOS app never touches Ktor directly; it calls the repository or observes the shared ViewModel. A suspend function exported to Swift becomes async throws with SKIE, and a completion-handler function without it.

// iosApp/iosApp/TrailListView.swift (excerpt)
import SwiftUI
import SharedLogic

struct TrailListView: View {
    let repository: TrailRepository // provided by Koin on the Kotlin side
    @State private var message = "Loading..."

    var body: some View {
        Text(message).task {
            do {
                let trails = try await repository.refresh(page: 1)
                message = "\(trails.count) trails"
            } catch {
                message = "Failed: \(error.localizedDescription)"
            }
        }
    }
}

Without SKIE a suspend function must be annotated with @Throws(CancellationException::class) or an uncaught Kotlin exception terminates the iOS process. See Consuming Coroutines and Flow from Swift and SwiftUI and Objective-C Interop, Swift Export and SKIE. See SKIE — Swift Kotlin Interface Enhancer.

Platform Behaviour

On Android declare android.permission.INTERNET in the app manifest (androidApp) and remember that cleartext HTTP is blocked by default. On iOS, App Transport Security requires HTTPS. In the browser the API must send CORS headers for your origin. Desktop has no permission model.

Migrating from Ktor 1.x to 3.x

Older tutorials, including many from the Kotlin Multiplatform Mobile era, use Ktor 1.x. The code does not compile on Ktor 3.x; these are the changes you will meet.

Ktor 1.x Ktor 3.x

ktor-client-json + ktor-client-serialization, install(JsonFeature)

ktor-client-content-negotiation + ktor-serialization-kotlinx-json, install(ContentNegotiation) { json() }

ktor-client-ios

ktor-client-darwin

Package io.ktor.client.features.*

Package io.ktor.client.plugins.* ("features" became "plugins" in 2.0)

HttpClient.get<Trail>(url) reified generic

client.get(url).body<Trail>()

Native freeze() workarounds, per-target -native artifacts

Not needed: new Kotlin/Native memory manager and Gradle module metadata

No Wasm support

ktor-client-js works on Kotlin/Wasm (Beta)

Migrate by first replacing JsonFeature and the artifact names, then fix imports from features to plugins, then change get<T>() calls to .body<T>(). Ktor 3.0 additionally changed engine configuration types and removed several deprecated APIs from 2.x; the official migration guides list each. See Ktor — Migrating from 1.x to 2.x and Ktor — Migrating from 2.x to 3.x.

Common Mistakes

  • Creating an HttpClient per request. Each client has its own connection pool; share one and close it at shutdown.

  • Forgetting the engine on a target. HttpClient() fails at runtime with "Failed to find HTTP client engine implementation"; add one engine dependency per target source set.

  • Leaving expectSuccess off. A 500 returns an HTML body that fails deserialization with a misleading message.

  • Sending the bearer token to every host. Restrict it with sendWithoutRequest.

  • Using Gson or Jackson converters (JVM-only), or forgetting @Serializable.

  • Retrying non-idempotent requests or logging bodies in production.

  • Swallowing CancellationException in a broad catch (e: Exception).