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:
ConcurrentHashMapfor thread-safe accessConcurrentHashMap.newKeySet()for thread-safe setsbroadcastsends 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 (
EventSourceAPI) - 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
- Not implementing ping-pong — Without heartbeat, dead connections are not detected.
- Broadcasting to disconnected clients — Always wrap
send()in try-catch and remove failed connections. - Not authenticating WebSocket connections — Anyone can connect if you do not check tokens.
- Blocking the coroutine — Use
suspendfunctions andDispatchers.IOfor 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.
Related Articles
- Ktor Tutorial #14: Rate Limiting and Security — API security
- Ktor Tutorial #4: Routing — REST endpoints
- Kotlin Tutorial #19: Coroutines — Async fundamentals
- Kotlin Tutorial: Complete Series — Learn Kotlin from scratch