HTTP is a request-response protocol. The client sends a request, the server responds, and the connection closes. For real-time features like chat, notifications, or live updates, you need WebSockets.

In this tutorial, you will build a chat server with rooms, broadcasting, and heartbeat/ping-pong. You will learn how WebSockets work in Ktor and how to manage connections.

What Are WebSockets?

WebSockets provide a persistent, two-way connection between client and server. Both sides can send messages at any time without waiting for a request.

HTTP:        Client → Request → Server → Response → Done
WebSocket:   Client ←→ Server (persistent connection, messages both ways)

Use WebSockets when you need:

  • Chat applications
  • Live notifications
  • Real-time dashboards
  • Collaborative editing
  • Online games

Dependencies

Add the WebSocket plugin:

dependencies {
    implementation("io.ktor:ktor-server-websockets:$ktorVersion")

    // For testing WebSockets
    testImplementation("io.ktor:ktor-client-websockets:$ktorVersion")
}

Configure WebSockets

Install the WebSocket plugin with heartbeat settings:

fun Application.configureWebSockets() {
    install(WebSockets) {
        pingPeriod = 15.seconds
        timeout = 15.seconds
        maxFrameSize = Long.MAX_VALUE
        masking = false
    }
}

Ping-pong heartbeat keeps connections alive. The server sends a ping every 15 seconds. If the client does not respond with a pong within 15 seconds, the connection is considered dead and closed. This detects silently dropped connections.

Chat Room Manager

Create a ChatRoom object to manage connections and rooms:

@Serializable
data class ChatMessage(
    val sender: String,
    val text: String,
    val room: String,
    val timestamp: Long = System.currentTimeMillis()
)

data class ChatConnection(
    val session: WebSocketSession,
    val username: String,
    val room: String
)

object ChatRoom {
    private val rooms = ConcurrentHashMap<String, MutableSet<ChatConnection>>()

    fun join(connection: ChatConnection) {
        rooms.getOrPut(connection.room) { ConcurrentHashMap.newKeySet() }
            .add(connection)
    }

    fun leave(connection: ChatConnection) {
        rooms[connection.room]?.remove(connection)
        if (rooms[connection.room]?.isEmpty() == true) {
            rooms.remove(connection.room)
        }
    }

    suspend fun broadcast(room: String, message: ChatMessage) {
        val json = Json.encodeToString(message)
        rooms[room]?.forEach { connection ->
            try {
                connection.session.send(json)
            } catch (e: Exception) {
                leave(connection)
            }
        }
    }

    fun getUserCount(room: String): Int {
        return rooms[room]?.size ?: 0
    }
}

Key decisions:

  • ConcurrentHashMap for thread-safe access
  • ConcurrentHashMap.newKeySet() for thread-safe sets
  • broadcast sends JSON to all connections in a room
  • Failed sends remove the connection (it was probably disconnected)

WebSocket Routes

fun Route.webSocketRoutes() {
    webSocket("/ws/chat/{room}") {
        val room = call.parameters["room"] ?: "general"
        val username = call.request.queryParameters["username"] ?: "Anonymous"

        val connection = ChatConnection(this, username, room)
        ChatRoom.join(connection)

        // Notify room that user joined
        ChatRoom.broadcast(room, ChatMessage(
            sender = "System",
            text = "$username joined the room",
            room = room
        ))

        try {
            for (frame in incoming) {
                when (frame) {
                    is Frame.Text -> {
                        val text = frame.readText()
                        val message = ChatMessage(
                            sender = username,
                            text = text,
                            room = room
                        )
                        ChatRoom.broadcast(room, message)
                    }
                    else -> { /* Ignore non-text frames */ }
                }
            }
        } catch (e: Exception) {
            // Connection error
        } finally {
            ChatRoom.leave(connection)
            ChatRoom.broadcast(room, ChatMessage(
                sender = "System",
                text = "$username left the room",
                room = room
            ))
        }
    }
}

The for (frame in incoming) loop runs as long as the connection is open. When the client disconnects, the loop ends and the finally block runs.

JSON Messages

Messages are serialized as JSON:

{
    "sender": "Alex",
    "text": "Hello everyone!",
    "room": "general",
    "timestamp": 1721433600000
}

System messages use "System" as the sender:

{
    "sender": "System",
    "text": "Alex joined the room",
    "room": "general",
    "timestamp": 1721433600000
}

Testing WebSockets

Ktor’s test framework supports WebSocket testing:

@Test
fun `websocket chat sends and receives messages`() = testApplication {
    application { module() }
    val wsClient = createClient {
        install(WebSockets)
    }

    wsClient.webSocket("/ws/chat/testroom?username=Alex") {
        // Receive the join message
        val joinFrame = incoming.receive() as Frame.Text
        assertTrue(joinFrame.readText().contains("Alex joined the room"))

        // Send a message
        send("Hello everyone!")

        // Receive the echoed message
        val msgFrame = incoming.receive() as Frame.Text
        val msgText = msgFrame.readText()
        assertTrue(msgText.contains("Hello everyone!"))
        assertTrue(msgText.contains("Alex"))
    }
}

Client-Side JavaScript Example

<script>
const ws = new WebSocket('ws://localhost:8080/ws/chat/general?username=Alex');

ws.onmessage = (event) => {
    const message = JSON.parse(event.data);
    console.log(`${message.sender}: ${message.text}`);
};

ws.onopen = () => {
    ws.send('Hello from the browser!');
};

ws.onclose = () => {
    console.log('Disconnected');
};
</script>

Authenticated WebSockets

You can authenticate WebSocket connections using a JWT token in the query string:

webSocket("/ws/chat/{room}") {
    val token = call.request.queryParameters["token"]
    if (token == null) {
        close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Missing token"))
        return@webSocket
    }

    // Verify JWT manually
    val verifier = JWT.require(Algorithm.HMAC256(JwtConfig.SECRET))
        .withAudience(JwtConfig.AUDIENCE)
        .withIssuer(JwtConfig.ISSUER)
        .build()

    val decoded = try {
        verifier.verify(token)
    } catch (e: Exception) {
        close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Invalid token"))
        return@webSocket
    }

    val username = decoded.getClaim("email").asString()
    // Continue with the connection...
}

Server-Sent Events (SSE) Alternative

If you only need one-way communication (server to client), Server-Sent Events (SSE) is simpler than WebSockets:

get("/sse/notifications") {
    call.respondTextWriter(contentType = ContentType.Text.EventStream) {
        while (true) {
            write("data: {\"type\": \"ping\"}\n\n")
            flush()
            delay(5000)
        }
    }
}

Use SSE when:

  • You only need server-to-client messages
  • You want simpler client code (EventSource API)
  • You need automatic reconnection

Use WebSockets when:

  • You need two-way communication
  • You need low-latency messaging
  • You are building chat or games

Common Mistakes

  1. Not implementing ping-pong — Without heartbeat, dead connections are not detected.
  2. Broadcasting to disconnected clients — Always wrap send() in try-catch and remove failed connections.
  3. Not authenticating WebSocket connections — Anyone can connect if you do not check tokens.
  4. Blocking the coroutine — Use suspend functions and Dispatchers.IO for database operations inside WebSocket handlers.

Source Code

You can find the source code for this tutorial on GitHub:

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-15-websockets

What’s Next?

You have a real-time chat server with WebSockets. In the next tutorial, you will add OpenAPI and Swagger UI — auto-generated API documentation that other developers can use to understand your API.