-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Null Safety
-
Section 4: Object-Oriented Kotlin
-
Section 5: Functional Kotlin
-
Section 6: Coroutines
-
Section 7: Collections Deep Dive
-
Section 8: Type System Deep Dive
-
Section 9: Interop and Tooling
-
Section 10: Kotlin DSLs and Patterns
-
Section 11: Testing and Quality
-
Section 12: Server-Side Kotlin
-
Section 13: Practical Projects
-
Section 14: Interview Practice
-
Section 15: More Practice Exercises
-
Section 16: More Standard Library
-
Section 17: Multiplatform Kotlin
-
Section 18: kotlin.collections In Depth
-
Section 19: kotlin.text In Depth
-
Section 20: kotlin.ranges and kotlin.sequences
-
Section 21: kotlin.io and File Handling
-
Section 22: kotlinx.coroutines Deep Dive
-
Section 23: kotlin.reflect
-
Section 24: Android Development with Kotlin Overview
-
Section 25: Kotlin Multiplatform Deep Dive
-
Section 26: Kotlin for Backend Deep Dive
-
Section 27: Kotlin Design Patterns
-
Section 28: Advanced Language Features
-
Section 29: More Practice Exercises
-
Section 30: More Interview Practice
-
Section 31: Kotlin Type System Deep Dive
-
Section 32: Kotlin Null Safety Advanced
-
Section 33: Kotlin Testing Deep Dive
-
Section 34: Kotlin Build Tooling Deep Dive
-
Section 35: Kotlin Serialization
-
Section 36: Kotlin Performance Considerations
-
Section 37: Kotlin Native Overview
-
Section 38: Kotlin for Data and Scripting
-
Section 39: More Coroutines Practice
-
Section 40: More Android-Adjacent Patterns
-
Section 41: More Practical Projects
-
Section 42: More Design and Architecture Practice
-
Section 43: Kotlin Language Evolution
-
Section 44: More Interview and Review
-
Section 45: Kotlin Delegation Patterns Deep Dive
-
Section 46: Kotlin Annotations Deep Dive
-
Section 47: Kotlin for Gradle Plugin Development
-
Section 48: Kotlin Concurrency Beyond Coroutines
-
Section 49: Kotlin Compiler Internals
-
Section 50: Real-World Kotlin Case Studies
-
Section 51: Final Practice Projects
-
Section 52: Kotlin for Server-Side Reactive Programming
-
Section 53: More Practice and Drills
-
Section 54: Kotlin Security Practices
192: Building a Simple Chat Application with WebSockets in Ktor
I want to start with a snippet of code that looks perfectly reasonable on the surface. You've set up your Ktor server, installed the WebSockets plugin, and you're trying to maintain a list of active users so you can broadcast messages. Here is how a lot of people first attempt their chat logic:
val connections = Collections.synchronizedSet(mutableSetOf<DefaultWebSocketServerSession>()>)
routing {
webSocket("/chat") {
connections.add(this)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
connections.forEach { it.send(text) }
}
}
} finally {
// I'll remove them later, I guess?
}
}
}
The Crash When Someone Leaves
This code works great for the first five minutes. You open three browser tabs, send a few messages, and everything is snappy. But the moment one of those tabs is closed or a user loses their Wi-Fi connection, the whole thing falls apart. The next time someone sends a message, your server logs will explode with ClosedSendChannelException.
The problem is that connections.forEach { it.send(text) } is blindly trusting that every session in that set is still open. In the world of WebSockets, connections are fragile. A client can vanish without a formal "goodbye" handshake. If you try to push data into a closed pipe, Ktor throws an exception, and because that exception happens inside your message loop, it can kill the session of the person who actually sent the message.
Pruning Dead Connections on the Fly
To fix this, we need to be aggressive about cleanup. We can't just assume the connection is there; we have to handle the failure and use that failure as a signal to purge the stale session from our set.
val connections = Collections.synchronizedSet(mutableSetOf<DefaultWebSocketServerSession>()>)
routing {
webSocket("/chat") {
connections.add(this)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
val deadConnections = mutableListOf<DefaultWebSocketServerSession>()
connections.forEach { session ->
try {
session.send(text)
} catch (e: Exception) {
deadConnections.add(session)
}
}
connections.removeAll(deadConnections)
}
}
} finally {
connections.remove(this)
}
}
}
I added two critical things here. First, the finally block ensures that if the incoming loop terminates normally (like a clean close), the user is removed. Second, I'm wrapping the send call in its own try-catch. If a send fails, we mark that session as "dead" and prune it. I used a temporary deadConnections list because modifying a set while iterating over it will throw a ConcurrentModificationException—another classic Kotlin gotcha.
Broadcasting with a Shared Flow
While the set approach works for a tiny app, it's not how I'd build this for production. Iterating through a list and sending messages sequentially is slow. If you have 100 users and the 2nd user has a laggy connection, you're delaying the message for the other 98.
A more "Kotlin-idiomatic" way is to use a MutableSharedFlow. Think of it as a hot stream of messages that any active WebSocket session can subscribe to. This decouples the receiving of a message from the distributing of it.
val chatFlow = MutableSharedFlow<String>()
routing {
webSocket("/chat") {
// Launch a concurrent coroutine to collect from the flow and send to this client
launch {
chatFlow.collect { message ->
send(message)
}
}
// The main loop handles incoming messages from this client
for (frame in incoming) {
if (frame is Frame.Text) {
chatFlow.emit(frame.readText())
}
}
}
}
I love this pattern because it leverages Ktor's coroutine integration. The launch block creates a separate job for each user. If one user's connection is slow, it only affects their specific collect coroutine; it doesn't block the chatFlow.emit() call for everyone else. It's cleaner, more scalable, and removes the need to manually manage a synchronized set of sessions.
Filtering Frame Types
One last thing: remember that WebSockets aren't just for text. You'll encounter Frame.Binary, Frame.Close, and Frame.Ping/Pong. In a real chat app, you'll likely want to handle Frame.Binary if you're allowing image uploads, or explicitly handle Frame.Close if you want to send a "User has left the chat" notification to the remaining clients. Always check your frame type before calling readText()`, or you'll run into casting errors that are a pain to debug.
📋 Practical Task
Build a Topic-Based Chat Room System
Instead of one giant chat room, modify the Ktor server to support multiple "topics" using path parameters. Your goal is to ensure that messages sent to /chat/{topic} are only broadcast to other users currently connected to that same topic.
- Update the
webSocketroute to accept atopicparameter (e.g.,webSocket("/chat/{topic}")). - Implement a mechanism (such as a
Map<String, MutableSharedFlow<String>>) to keep track of different flows for different topics. - Ensure that when a user joins a topic that doesn't exist yet, a new flow is created for that topic.
- Verify that a user in the "kotlin" topic cannot see messages being sent in the "java" topic.
There are no comments for now.