🐛 How I Discovered and Fixed a WebSocket Session Bug in Ktor

Holy_DevHoly_Dev
2 min read

As part of my video series on Creating a WebSocket Android Application using Ktor in IntelliJ IDEA Ultimate, I was building the backend WebSocket server with Ktor and testing it using Postman.

My WebSocket server had a simple echo logic:

  • When a message is received, echo it back

  • When the user types bye, respond with a goodbye and close the connection

Here was my route:

webSocket("/ws") {
    for (frame in incoming) {
        if (frame is Frame.Text) {
            val text = frame.readText()
            if (text.equals("bye", ignoreCase = true)) {
                outgoing.send(Frame.Text("YOU SAID: bye"))
                close(CloseReason(CloseReason.Codes.NORMAL, "Client said BYE"))
            } else {
                outgoing.send(Frame.Text("YOU SAID: $text"))
            }
        }
    }
}

Problem Observed

When I sent the message bye in Postman, the server responded with:

YOU SAID: bye

But the WebSocket connection didn’t actually close.

This caused confusion during debugging, because I expected the session to close immediately.

Root Cause

The root issue is that Ktor doesn’t automatically exit the coroutine or loop after calling close().

Your coroutine is still alive and continues iterating over the incoming channel.

Key Insight

The for (frame in incoming) loop will keep running even after you call close(), unless you explicitly break or return.

Solution: Add break After close()

Here’s the fixed version:

webSocket("/ws") {
    for (frame in incoming) {
        if (frame is Frame.Text) {
            val text = frame.readText()
            if (text.equals("bye", ignoreCase = true)) {
                outgoing.send(Frame.Text("YOU SAID: bye"))
                close(CloseReason(CloseReason.Codes.NORMAL, "Client said BYE"))
                break // ✅ Exit the loop after closing
            } else {
                outgoing.send(Frame.Text("YOU SAID: $text"))
            }
        }
    }
}

Summary of the Fix

  • 🟢 Send a message

  • 🟢 Close the connection

  • ✅ Break the loop so your coroutine exits gracefully

What I Learned

  • Ktor gives full control, which means you’re responsible for coroutine lifecycle

  • Closing the session doesn’t stop incoming.consumeEach or for (frame in incoming) automatically

  • Always break or return after close() when done

🙌 Recommendation

If you're writing Ktor WebSocket handlers:

  • ✅ Always handle session cleanup explicitly

  • ✅ Call break after close() to avoid silent coroutine hanging


🔗 Follow my full series on [Creating a WebSocket Android App Using Ktor] — where I build both the Ktor server and the Jetpack Compose client, step by step!

0
Subscribe to my newsletter

Read articles from Holy_Dev directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Holy_Dev
Holy_Dev

strong desire for learning new things