Why Ktor WebSockets are a Practical Choice for Real‑Time Apps
Ktor’s WebSocket feature lets you build real‑time APIs with minimal code. Learn how to route, send, and receive frames, see a full echo example, and understand the trade‑offs for scaling and performance.
01 Oct 2025, 03:11 UTC

Concrete Problem: Need a Real‑Time API with Minimal Boilerplate
Imagine you’re building a chat service or a live dashboard. You need a bidirectional channel that keeps a single TCP connection alive, pushes updates instantly, and scales without pulling in a heavyweight framework. The question is: can you do this in Kotlin with a single dependency, without running a full Spring stack or a Node server?
The answer, in most cases, is Ktor’s built‑in WebSocket support. It plugs into Ktor’s coroutine engine, giving you non‑blocking, back‑pressure‑aware streams with a clean, type‑safe API.
Thesis: Ktor’s WebSocket API is the sweet spot between simplicity and performance
Because WebSockets are just an HTTP upgrade, Ktor exposes a webSocket("/path") route that gives you a WebSocketSession object. From there you can send and incoming frames in a suspending context, and you can combine it with ContentNegotiation to serialize JSON automatically. No extra libraries, no manual thread pools.
Routing & Session Basics
- Route declaration:
webSocket("/chat") { /* handler */ } - Incoming frames:
for (frame in incoming) { /* process */ } - Sending frames:
send(Frame.Text("hello"))orsend("hello")withContentNegotiation - Configuration:
pingPeriod,timeout,contentConverterare set ininstall(WebSockets)
Worked Example: Echo Server & Client
Below is a minimal echo implementation that demonstrates the core concepts. It is under 30 lines of code, uses Ktor 2.3.0, and requires Kotlin 1.9.
// build.gradle.kts
plugins { kotlin("jvm") version "1.9.0" }
repositories { mavenCentral() }
dependencies {
implementation("io.ktor:ktor-server-core:2.3.0")
implementation("io.ktor:ktor-server-netty:2.3.0")
implementation("io.ktor:ktor-server-websockets:2.3.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.0")
}
// Server.kt
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.http.cio.websocket.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*
@Serializable data class Message(val text: String)
fun main() = embeddedServer(Netty, port = 8080) {
install(WebSockets) {
pingPeriod = 30_000L
timeout = 60_000L
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
routing {
webSocket("/echo") {
for (frame in incoming) {
when (frame) {
is Frame.Text -> send(Frame.Text(frame.readText()))
is Frame.Binary -> send(Frame.Binary(frame.data))
else -> Unit
}
}
}
}
}.start(wait = true)
// Client.kt
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.cio.websocket.*
suspend fun main() {
val client = HttpClient(CIO)
client.webSocket("ws://localhost:8080/echo") {
send(Frame.Text("Hello, server!"))
val response = incoming.receive() as? Frame.Text
println("Received: ${response?.readText()}")
}
client.close()
}
Run the server, then execute Client.kt in a separate process. The client should print Received: Hello, server! – that’s the echo.
Trade‑Offs & Limitations
- Single event loop: Netty’s default engine runs one event loop per thread. Heavy CPU work inside the WebSocket handler can block the loop. Wrap blocking calls in
withContext(Dispatchers.IO)or offload to a dedicated thread pool. - Scaling horizontally: Ktor does not provide built‑in session affinity. If you need to scale across machines, pair the WebSocket server with a message broker (Kafka, Redis) or use a reverse proxy that balances connections.
- Browser support: Browsers use WebSocket over HTTP/1.1 or HTTP/2. Ktor supports both, but if you need long‑polling fallbacks for legacy browsers, you’ll need an additional layer.
- Security: WebSocket connections are subject to the same CORS rules. Add
install(CORS)if clients run from a different origin. - Version drift: API surface changes between minor releases. Always lock to a specific Ktor version in production.
Actionable Checklist
- Add
ktor-server-websocketsandktor-serialization-kotlinx-jsonto your Gradle dependencies. - Configure
install(WebSockets)with desiredpingPeriod,timeout, andcontentConverter. - Define a
webSocket("/route")handler, processincomingframes, and send responses withsend. - For blocking I/O, wrap calls in
withContext(Dispatchers.IO)to keep the event loop free. - Test locally, then run a simple load test (e.g.,
wrkork6) to confirm back‑pressure works. - If scaling, integrate a message broker or use a load balancer that preserves sticky sessions.
- Deploy behind HTTPS and enable
CORSif needed.
By following these steps, you’ll have a lightweight, coroutine‑friendly WebSocket server that can grow with your real‑time needs.
Conclusion
Ktor’s WebSocket support gives you everything you need for a real‑time API without the overhead of a full framework. Its coroutine‑based API keeps the code concise, and the built‑in configuration options let you fine‑tune latency and reliability. Just be mindful of the single event loop and plan for horizontal scaling if your traffic spikes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.