Retrofit vs Ktor in 2026: An Honest Comparison
One input decides this: whether you're shipping Kotlin Multiplatform. Everything else — the interceptor ecosystem, testability, streaming support, and the failure-mode defaults that differ dangerously — is what you should weigh once that's answered.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — If you ship or will ship Kotlin Multiplatform, use Ktor — Retrofit is JVM-only and
there's no debate to have. If you're Android-only, Retrofit + OkHttp remains the lower-risk choice,
mostly for the OkHttp interceptor ecosystem and because a Retrofit interface is a type you can fake in
tests. Ktor wins independently on streaming (SSE, WebSockets, progressive bodies). The dangerous
difference is the failure default: Retrofit throws on a 404, Ktor hands it back as a normal response
unless you set expectSuccess = true. On Android both run on OkHttp anyway.
The one input that decides it
Everything else in this post is secondary to this question: is a shared Kotlin module part of the plan within the next year?
If yes, Ktor. Retrofit runs on the JVM; there is no iOS target, and no amount of preference changes that. Choosing Retrofit now means writing the networking layer twice when the KMP work starts, or migrating under deadline pressure — which is the expensive version.
If no — genuinely no, not "maybe someday" — then the comparison is real, and it isn't close to a tie.
Why Retrofit still wins the Android-only case
Not the annotations. Three concrete things.
1. A decade of OkHttp interceptors. Auth token refresh with single-flight semantics, certificate pinning, Chucker for in-app traffic inspection, retry-with-backoff, response caching, request signing for a partner API — these exist, are battle-tested, and drop in:
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenStore))
.addInterceptor(ChuckerInterceptor(context))
.addNetworkInterceptor(CacheControlInterceptor())
.build()
Ktor's plugin API is well-designed and the Auth plugin covers bearer refresh properly. But for the
long tail — the vendor SDK that ships an Interceptor, the internal library your platform team wrote
in 2021 — you're writing the Ktor equivalent yourself. That's a real, recurring cost that doesn't show
up in a feature-matrix comparison.
2. The interface is a fake. This one is underrated:
interface UserApi {
@GET("users/{id}")
suspend fun user(@Path("id") id: String): UserDto
}
// In tests — no HTTP, no engine, no JSON
class FakeUserApi(private val users: Map<String, UserDto>) : UserApi {
override suspend fun user(id: String) = users[id] ?: error("not found")
}
Your repository tests never touch the network layer at all. Ktor's MockEngine is good, and it tests
one level lower — you're stubbing HTTP responses rather than the API contract. That's occasionally what
you want (verifying serialisation, headers, retry behaviour) and usually one level too deep for a
repository test.
You can recover this with Ktor by hand-writing an interface over the client and implementing it, which is a fine pattern — just note that it's a pattern you have to impose, not one the library gives you.
3. Routes are checked. @GET("users/{id}") paired with @Path("id") is validated; a mismatch is a
build-time failure. client.get("uesrs/$id") is a string, and strings are checked by QA.
Where Ktor genuinely wins, KMP aside
Streaming. SSE, WebSockets, and reading a body progressively are first-class:
client.sse("v1/chat/stream") {
incoming.collect { event -> emit(event.data) }
}
With Retrofit, streaming means dropping to @Streaming and a raw ResponseBody, or bypassing Retrofit
for OkHttp directly. In 2026, when a meaningful share of features are token-by-token model responses,
this is not a niche concern any more — it's the single strongest non-KMP argument for Ktor.
Explicit request construction. Ktor's DSL is imperative, so a request whose shape depends on
runtime state is straightforward. Retrofit's declarative interface gets awkward once you have optional
query maps, conditional headers, and three body variants — you end up with @QueryMap and a builder
that reads worse than the Ktor version.
Consistent multiplatform behaviour. One HttpClient configuration, one set of plugins, one retry
policy across Android and iOS. That's a maintenance argument, and it grows with time.
The failure default that bites in production
The most important line in this post:
// Retrofit: a 404 throws HttpException. Loud, in QA.
val user = api.user(id)
// Ktor: a 404 is a perfectly normal response.
val response = client.get("users/$id") // no exception
response.status // 404 — you have to look
// Opt in, once, at client construction:
HttpClient(OkHttp) {
expectSuccess = true
}
Without expectSuccess, response.body<UserDto>() on an error payload either throws a confusing
deserialisation error or — if your error body happens to have compatible fields — silently produces a
UserDto full of defaults. I've seen the second one ship.
Both libraries need a deliberate error model regardless. Neither gives you one:
sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class HttpError(val code: Int, val body: String?) : ApiResult<Nothing>
data class NetworkError(val cause: IOException) : ApiResult<Nothing>
}
Wrap at the data-source boundary, once, and never let an HttpException or a raw HttpResponse reach
a ViewModel. This decision matters far more than the library choice, and teams that get it right are
fine on either.
Things that turn out not to matter
- Performance. On Android, Ktor's default engine is OkHttp. You're picking an API surface, not a network stack. Any difference is measurement noise beside your JSON size and your server.
- Serialisation. Both use
kotlinx.serializationcleanly. Retrofit needs one converter factory line. - Coroutine support. Both are suspend-native. This stopped being a differentiator years ago.
- Binary size. Both land in the same order of magnitude after R8. If your APK budget hinges on this, the problem is elsewhere.
Choosing, in four questions
- KMP now or within a year? → Ktor. Stop here.
- Is streaming (SSE/WebSocket) core to the product? → Ktor.
- Do you depend on OkHttp interceptors you didn't write? → Retrofit, or budget the rewrite.
- Otherwise: whichever your team already knows. Familiarity beats a marginal API preference every time, and it isn't close.
And if you're on Retrofit today with no KMP plan: don't migrate. A networking migration touches every feature, risks every error path, and delivers zero user-visible value. Migrate when requirement 1 or 2 arrives, and let it pay for itself then.
The rule
Pick by platform reach and streaming needs; everything else is taste, and taste doesn't justify a migration. Then spend the time you saved on the thing that actually differs at scale: a single, explicit error model at the data-source boundary — because neither library will give you one, and that's what your on-call rotation will judge you on.