Ktor 2.x JSON Serialization with ContentNegotiation: Engine-Independent Setup and Migration Tips
Learn how Ktor 2.x's ContentNegotiation plugin provides engine-independent JSON serialization with kotlinx.serialization, including a sealed-class example, migration gotchas from Ktor 1.x, and a verification checklist.
19 Apr 2026, 08:39 UTC

The problem: manual header wrangling and fragile JSON mapping
Moving a Kotlin service to Ktor 2.x removes the old JsonFeature. You end up setting Accept and Content-Type yourself, registering custom serializers, and hoping the same code works on CIO, Jetty or Apache. The result is boilerplate and runtime SerializationException that only appears in production.
Thesis: ContentNegotiation gives a single engine-agnostic configuration point
Ktor 2.x ships the ContentNegotiation plugin for the HTTP client. Install it and point it at a kotlinx.serialization JSON serializer and the plugin adds the correct headers, negotiates the media type, and serializes and deserializes request and response bodies. Because the plugin lives in the client pipeline, the same configuration works unchanged across engines.
Worked example: POST a sealed-class payload and read the response
Domain model:
@Serializable
sealed class Event {
@Serializable data class Created(val id: String, val timestamp: Long) : Event()
@Serializable data class Updated(val id: String, val version: Int) : Event()
}
Gradle dependencies:
dependencies {
implementation("io.ktor:ktor-client-core:2.3.8")
implementation("io.ktor:ktor-client-content-negotiation:2.3.8")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.8")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("io.ktor:ktor-client-cio:2.3.8")
}
Client configuration:
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
serializersModule = SerializersModule {
polymorphic(Event::class) {
subclass(Event.Created::class)
subclass(Event.Updated::class)
}
}
ignoreUnknownKeys = true
})
}
install(Logging) {
level = LogLevel.HEADERS
}
}
Request and response:
suspend fun postEvent(event: Event): Event {
val response = client.post("https://api.example.com/events") {
contentType(ContentType.Application.Json)
setBody(event)
}
return response.body()
}
Swap CIO for Jetty in the HttpClient constructor and the serialization behavior stays identical.
Trade-offs and limitations
- Runtime failures: Missing @Serializable or an unregistered polymorphic subclass throws SerializationException at call time. Add a unit test that serializes each subclass.
- Blocking serializers: Custom KSerializer that does I/O or heavy work blocks the client engine thread and reduces throughput. Keep serializers pure.
- Migration effort: Ktor 1.x JsonFeature does not map 1:1 to ContentNegotiation. Rewrite feature registration and move type adapters into a SerializersModule.
Verification checklist
- Run a local test server that echoes posted JSON.
- Call postEvent with Event.Created and assert the returned object equals the sent one.
- Check logs for Accept: application/json and Content-Type: application/json.
- Repeat with Jetty and Apache engines by changing only the engine argument.
- Add a negative test with an unknown subclass and verify a clear SerializationException surfaces.
If checks pass you have a portable type-safe JSON pipeline that survives engine upgrades.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.