JSON, serialization and HTTP clients

kotlinx.serialization with custom serializers, Ktor Client and Retrofit, timeouts, retries and mapping transport errors into your own types.

kotlinx.serialization

import kotlinx.serialization.*
import kotlinx.serialization.json.*
import java.time.Instant

@Serializable
data class Article(
    val id: Long,
    val title: String,
    @SerialName("published_at") val publishedAt: Instant,
    val author: Author? = null,
    val tags: List<String> = emptyList(),
)

@Serializable
data class Author(val name: String, val url: String? = null)

// custom serializer for a type the library does not know
object InstantSerializer : KSerializer<Instant> {
    override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
    override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
    override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
}

val json = Json {
    ignoreUnknownKeys = true          // tolerate a server that adds fields
    explicitNulls = false             // omit nulls when encoding
    encodeDefaults = false
    coerceInputValues = true          // fall back to the default on a wrong type
}
SettingEffectRisk
ignoreUnknownKeysNew server fields do not break the clientTypos in field names are silently ignored
isLenientAccepts unquoted keys and valuesHides malformed payloads
explicitNullsControls whether nulls are emittedA server may require an explicit null
coerceInputValuesReplaces an invalid value with the defaultA real data problem looks like a missing value

Ktor Client and Retrofit

import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*

val client = HttpClient {
    install(ContentNegotiation) { json(json) }
    install(HttpTimeout) {
        requestTimeoutMillis = 15_000
        connectTimeoutMillis = 5_000
        socketTimeoutMillis = 15_000
    }
    install(HttpRequestRetry) {
        retryOnServerErrors(maxRetries = 2)
        exponentialDelay()
    }
    defaultRequest {
        url("https://api.example.com/")
        header("Accept", "application/json")
    }
}

suspend fun articles(client: HttpClient): List<Article> =
    client.get("articles") { parameter("page", 1) }.body()

// Retrofit with the kotlinx converter, if you prefer annotation-based interfaces
interface ArticleApi {
    @GET("articles")
    suspend fun articles(@Query("page") page: Int): List<Article>

    @POST("articles")
    suspend fun create(@Body draft: ArticleDraft): Article
}
  • Ktor Client is multiplatform and plugin-oriented; Retrofit is JVM-first, reflection-based and extremely concise for a fixed REST API.
  • Set connection, request and socket timeouts separately. A single overall timeout hides which stage is slow.
  • Retry only idempotent requests by default; a POST retried without an idempotency key can create duplicates.
  • Close the client when it is no longer needed — it owns a connection pool and a thread pool.

Mapping transport failures

suspend fun loadArticle(id: Long): Either<Article> = try {
    Either.Ok(client.get("articles/$id").body())
} catch (e: CancellationException) {
    throw e
} catch (e: ClientRequestException) {        // 4xx
    if (e.response.status == HttpStatusCode.NotFound) Either.Err(LoadError.NotFound)
    else Either.Err(LoadError.Http(e.response.status.value, null))
} catch (e: ServerResponseException) {       // 5xx
    Either.Err(LoadError.Http(e.response.status.value, null))
} catch (e: HttpRequestTimeoutException) {
    Either.Err(LoadError.Network(e))
} catch (e: IOException) {
    Either.Err(LoadError.Network(e))
} catch (e: SerializationException) {
    Either.Err(LoadError.Decoding("/articles"))
}
⚠️
Ktor's default expectSuccess setting throws on a non-2xx response. If you disable it to inspect the status yourself, remember that every call must then check response.status — an unchecked 500 body decodes into an empty object and looks like success.

FAQ

Ktor Client or Retrofit for a new Android app?
Both are excellent. Retrofit has the shorter definition for a stable REST API and huge community knowledge; Ktor Client is the better choice if you share code with iOS, the browser or a server, or want plugins for logging, retries and auth in one place.
How do I keep a token fresh for every request?
Install an auth plugin or interceptor that adds the header, and on a 401 refresh once and replay the original request. Serialise concurrent refreshes through a mutex so ten parallel 401s produce one refresh, not ten.

Building a REST service with Ktor Exceptions, Result and error-handling patterns

Last refreshed 2026-09-18.