Skip to Content
Course content

192: Building a Simple Chat Application with WebSockets in Ktor

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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 webSocket route to accept a topic parameter (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.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.