Ktor ContentNegotiation: A Practical Guide to JSON Serialization with kotlinx.serialization
Ktor’s ContentNegotiation plugin with kotlinx.serialization turns JSON handling into a declarative line of code. This blog walks through setup, a concrete example, performance trade‑offs, common pitfalls, and a quick benchmark test.
10 Sept 2026, 11:39 UTC

Why Ktor’s ContentNegotiation matters
When building REST APIs in Ktor, you almost always need to convert Kotlin objects to JSON and back. The ContentNegotiation plugin, powered by kotlinx.serialization, turns this repetitive work into a single declarative line. It handles request body parsing, response serialization, and error handling with minimal boilerplate.
Setting up the plugin
// build.gradle.kts
implementation("io.ktor:ktor-server-core:2.3.0")
implementation("io.ktor:ktor-server-content-negotiation:2.3.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
In your application module you install the plugin once:
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
Notice the json() block: it registers the kotlinx.serialization converter for ContentType.Application.Json. Any route that receives or returns application/json will automatically use this configuration.
Example: a simple DTO and a POST endpoint
@Serializable
data class User(val id: Int, val name: String, val email: String)
routing {
post("/users") {
val user = call.receive<User>() // deserializes JSON body
call.respond(HttpStatusCode.Created, user) // serializes User back to JSON
}
}
Send a request:
curl -X POST \
-H "Content-Type: application/json" \
-d '{"id":1,"name":"Alice","email":"alice@example.com"}' \
http://localhost:8080/users
Expected response (pretty‑printed because of the plugin config):
{
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}
Performance and payload comparison
Benchmarks in Ktor 2.3 show that kotlinx.serialization typically outperforms Jackson for flat DTOs: kotlinx achieves ~20% higher throughput and ~10% smaller JSON size. However, for complex polymorphic hierarchies or large collections, the difference narrows, and Jackson’s streaming API can be more efficient.
To compare in your own project, add a simple JMH test or a measureTimeMillis block that serializes/deserializes the same data class with both libraries. Keep an eye on the kotlinx-serialization-json version; newer releases include optimizations for primitive arrays.
Common pitfalls and how to avoid them
- Missing @Serializable: Every data class that flows through the plugin must be annotated. Otherwise
call.receivethrows aSerializationException. - Version mismatch: The plugin expects the same major version of
kotlinx-serialization-jsonas Ktor. Using an olderkotlinxlibrary can cause runtime errors. - Polymorphic types: When you need to deserialize a sealed class hierarchy, register the polymorphic module:
install(ContentNegotiation) { json(Json { serializersModule = SerializersModule { polymorphic(Shape::class) { subclass(Circle::class, Circle.serializer()) subclass(Rectangle::class, Rectangle.serializer()) } } }) } - Unsupported content type: If a client sends
Content-Type: text/plain, the plugin will not attempt to parse it. Explicitly checkcall.request.contentType()if you need custom handling.
When to use a different serializer
While kotlinx.serialization is the default, Ktor allows you to plug in Jackson, Gson, or your own converter. If your project already relies on Jackson for other parts (e.g., Spring integration) or you need advanced features like mix‑ins, you can replace the plugin:
install(ContentNegotiation) {
register(ContentType.Application.Json, JacksonConverter())
}
However, remember that each converter has its own set of dependencies and potential conflicts.
Actionable checklist
- Add the three Ktor dependencies and the
kotlinx-serialization-jsonlibrary. - Install
ContentNegotiationwith ajson()block. - Annotate all DTOs with
@Serializable. - Write routes using
call.receive<T>()andcall.respond. - Run a quick benchmark or
curltest to confirm JSON round‑trip. - Monitor logs for
SerializationExceptionorUnsupportedContentTypeExceptionto catch misconfigurations early.
By following these steps you’ll have a clean, efficient JSON pipeline in Ktor with minimal boilerplate and predictable performance.
Limitations to keep in mind
- Polymorphic support requires explicit module registration; otherwise deserialization fails.
- Large payloads (hundreds of MB) may exhaust the JVM heap when fully deserialized; consider streaming or chunked responses.
- If you need custom field naming strategies (e.g., snake_case), you must configure
Json { namingStrategy = ... }or write a custom serializer.
Despite these nuances, ContentNegotiation remains the most straightforward way to handle JSON in Ktor.
Final thought
Choosing kotlinx.serialization for Ktor’s ContentNegotiation gives you type‑safe, fast, and concise JSON handling. Just remember to keep versions aligned, annotate your data classes, and register polymorphic modules when needed. Once set up, the plugin keeps your routing code clean and your API responses consistent.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.