This page looks best with JavaScript enabled

From Retrofit/OkHttp to Ktor in Kotlin Multiplatform: A Practical First Migration

 ·  ☕ 6 min read  ·  ✍️ Ignacio Carrión

From Retrofit/OkHttp to Ktor in Kotlin Multiplatform: A Practical First Migration

If you want to start migrating an existing Android app to Kotlin Multiplatform (KMP), the networking layer is an excellent first step. Ktor Client works across platforms and lets you keep a single HTTP stack for Android, iOS, Desktop, and more. This guide shows how to migrate from Retrofit/OkHttp to Ktor with either CIO or OkHttp engines — while keeping the impact limited to the remote layer when your architecture is clean.


Why Networking First? And What Should Change?

With good modular architecture (e.g., Clean Architecture + Repository/DataSources), only your remote DataSources implementations should change:

  • Domain layer (entities, use cases) remains untouched
  • Repository contracts remain the same
  • Local data sources (e.g., Room/SQLDelight) remain the same
  • Only Remote data source implementation swaps Retrofit → Ktor

This keeps risk small and the migration incremental.


Dependencies and Setup (KMP Shared Module)

Add Ktor Client and Kotlinx Serialization to your shared module. Use CIO or OkHttp engine on Android; use CIO or Darwin on iOS (CIO lets both mobile platforms share the same engine). Keep versions aligned with your Kotlin/AGP setup.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// build.gradle.kts of shared module
plugins {
    kotlin("multiplatform")
    id("com.android.library")
    kotlin("plugin.serialization") // for kotlinx.serialization
}

kotlin {
    androidTarget()
    iosX64(); iosArm64(); iosSimulatorArm64()

    sourceSets {
        val ktorVersion = "2.3.12"
        val commonMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-core:$ktorVersion")
                implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
                implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
                implementation("io.ktor:ktor-client-logging:$ktorVersion")
                // Optional: Resources, Auth, etc.
                // implementation("io.ktor:ktor-client-auth:$ktorVersion")
            }
        }
        val androidMain by getting {
            dependencies {
                // Choose ONE: OkHttp or CIO
                implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
                // implementation("io.ktor:ktor-client-cio:$ktorVersion")
            }
        }
        val iosMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-cio:$ktorVersion") // or use Darwin for the native stack
                // implementation("io.ktor:ktor-client-darwin:$ktorVersion")
            }
        }
    }
}

Tip: Prefer CIO on both Android and iOS to share the same engine: use ktor-client-cio on both. Alternatively, keep OkHttp on Android (ktor-client-okhttp) and Darwin on iOS (ktor-client-darwin).


HttpClient Factory with Engines (CIO or OkHttp)

Create a single place to configure your HttpClient. Use expect/actual to provide the engine per platform and to keep the rest of your code common.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// commonMain (imports omitted in snippet)
expect fun provideEngine(): HttpClientEngineFactory<*>

fun createHttpClient(baseUrl: String, enableLogs: Boolean = true): HttpClient =
    HttpClient(provideEngine()) {
        expectSuccess = false // we will handle non-2xx manually

        install(ContentNegotiation) {
            json(
                Json {
                    ignoreUnknownKeys = true
                    isLenient = true
                    encodeDefaults = true
                }
            )
        }

        install(HttpTimeout) {
            requestTimeoutMillis = 15_000
            connectTimeoutMillis = 10_000
            socketTimeoutMillis = 15_000
        }

        if (enableLogs) {
            install(Logging) {
                logger = Logger.DEFAULT
                level = LogLevel.INFO
            }
        }

        // Default headers or base URL can be handled per request or via a helper
    }

Provide platform engines:

1
2
// androidMain (imports omitted in snippet)
actual fun provideEngine(): HttpClientEngineFactory<*> = OkHttp
1
2
// jvmMain (if used) (imports omitted in snippet)
actual fun provideEngine(): HttpClientEngineFactory<*> = CIO
1
2
// iosMain (imports omitted in snippet)
actual fun provideEngine(): HttpClientEngineFactory<*> = CIO // or Darwin if you prefer the native stack

OkHttp-specific engine configuration (optional):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// androidMain - inside HttpClient(OkHttp) { engine { ... } } if you inline it
engine {
    config {
        followRedirects(true)
        retryOnConnectionFailure(true)
    }
    addInterceptor { chain ->
        val request = chain.request().newBuilder()
            .header("X-App-Version", "1.0")
            .build()
        chain.proceed(request)
    }
}

CIO-specific tuning:

1
2
3
4
5
6
// jvmMain - with CIO
engine {
    requestTimeout = 15_000
    threadsCount = 4
    pipelining = true
}

From Retrofit Services to Ktor Calls

Typical Retrofit service:

1
2
3
4
5
6
7
8
// Retrofit
interface UserService {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: String): UserDto

    @POST("users")
    suspend fun createUser(@Body body: CreateUserRequest): UserDto
}

Ktor replacement in the remote implementation (keep the interface you already use in your repository):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Common remote contract used by Repository
data class CreateUserRequest(val name: String)

interface UserRemote {
    suspend fun getUser(id: String): UserDto
    suspend fun createUser(body: CreateUserRequest): UserDto
}

class UserRemoteImpl(
    private val client: HttpClient,
    private val baseUrl: String,
) : UserRemote {
    override suspend fun getUser(id: String): UserDto =
        client.get("$baseUrl/users/$id").body()

    override suspend fun createUser(body: CreateUserRequest): UserDto =
        client.post("$baseUrl/users") {
            contentType(io.ktor.http.ContentType.Application.Json)
            setBody(body)
        }.body()
}

Query parameters and headers map directly:

1
2
3
4
5
6
7
client.get("$baseUrl/search") {
    url {
        parameters.append("q", "john")
        parameters.append("page", "1")
    }
    headers.append("Authorization", "Bearer $token")
}

Handling Errors and Mapping Them

Unlike Retrofit, Ktor won’t throw for non-2xx if expectSuccess = false. Handle responses explicitly or catch client/server exceptions.

1
2
3
4
5
6
7
8
suspend fun <T> HttpResponse.safeBody(): T {
    if (status.isSuccess()) return body()
    val raw = bodyAsText()
    // Parse domain error if your backend returns structured error JSON
    throw ApiException(status, raw)
}

class ApiException(val status: HttpStatusCode, message: String) : Exception(message)

Using it:

1
2
val response = client.get("$baseUrl/users/$id")
val user: UserDto = response.safeBody()

Alternatively, set expectSuccess = true and catch ClientRequestException/ServerResponseException.


Auth, Interceptors, and Logging

  • DefaultRequest: set headers applied to every request
  • Auth plugin: Bearer/Basic flows if you prefer
  • Logging plugin: request/response logs (avoid PII in production)
1
2
3
4
5
6
7
8
9
HttpClient(provideEngine()) {
    install(DefaultRequest) {
        headers.append("Accept", "application/json")
        // headers.append("Authorization", "Bearer $token") // inject token via DI
    }
    install(Logging) {
        level = LogLevel.HEADERS
    }
}

With OkHttp engine, you can also reuse existing OkHttp Interceptors (e.g., network inspection, custom retries) as shown earlier.


Keep the Architecture Boundary Intact

  • Keep your UserRemote (or similar) interface in commonMain
  • Migrate only the implementation from Retrofit → Ktor
  • Repositories depend on the interface, so the rest of the app doesn’t change
1
2
3
class UserRepository(private val remote: UserRemote) {
    suspend fun getUser(id: String) = remote.getUser(id)
}

Testing with Ktor MockEngine

You can test your remote implementation without hitting the network using MockEngine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fun testClient(): HttpClient = HttpClient(MockEngine) {
    engine {
        addHandler { request ->
            if (request.url.fullPath == "/users/123") {
                respond(
                    content = """{"id": "123", "name": "Jane"}""",
                    headers = headersOf(HttpHeaders.ContentType, "application/json"),
                    status = HttpStatusCode.OK
                )
            } else respondError(HttpStatusCode.NotFound)
        }
    }
    install(ContentNegotiation) { json() }
}

Migration Checklist

  • Add Ktor and kotlinx-serialization dependencies to shared module
  • Create HttpClient factory with engine per platform (Android: OkHttp or CIO; iOS: CIO or Darwin)
  • Install ContentNegotiation(Json), HttpTimeout, Logging
  • Port your Retrofit services to a Ktor-based remote implementation
  • Keep repository/domain unchanged; swap only the remote implementation in DI
  • Handle errors consistently; consider a Result/Either wrapper
  • Write MockEngine tests for happy/error paths

Frequently Asked Choices

  • CIO vs OkHttp on Android?
    • OkHttp: reuse existing interceptors/TLS/pinning; familiar debugging
    • CIO: pure Ktor/JVM engine, fewer external deps
  • Serialization: kotlinx-serialization is idiomatic in KMP; migrate DTOs to @Serializable
  • Caching: implement at repository layer; Ktor doesn’t impose policy

Conclusion

Migrating Retrofit/OkHttp to Ktor is a focused, low-risk first step toward KMP. With a clean architecture, you only change remote interfaces/implementations while the rest of the app remains stable. Start with a single feature, validate with tests, and expand confidently across your codebase.

Share on

Ignacio Carrión
WRITTEN BY
Ignacio Carrión
Android Developer