WebSocket #
WebSocket is a communication protocol that enables a persistent, bidirectional connection between a browser (or other client) and a server, over a single TCP connection. Unlike HTTP, which is request-response — the client asks, the server answers, done — WebSocket keeps the connection open so the server can at any time push data to the client without being asked first. This is the foundation of real-time features like chat, live notifications, live dashboards, and document collaboration. Kotlin has excellent WebSocket support through Ktor, Spring Boot, and Vert.x. This article covers how WebSocket works, a complete implementation with Ktor as the main focus, session management, broadcast patterns, and when to use WebSocket over other alternatives.
How WebSocket Works #
WebSocket starts as a regular HTTP connection, then is upgraded to the WebSocket protocol through a handshake:
sequenceDiagram
participant C as Client (Browser)
participant S as Server
C->>S: HTTP GET /ws\nUpgrade: websocket\nConnection: Upgrade
S->>C: HTTP 101 Switching Protocols\nUpgrade: websocket
Note over C,S: TCP connection stays open — full duplex
C->>S: Frame: "Hello server!"
S->>C: Frame: "Hello client!"
S->>C: Frame: "Notification: you have a new message"
C->>S: Frame: PING
S->>C: Frame: PONG
C->>S: Frame: CLOSE
S->>C: Frame: CLOSEAfter the handshake, communication happens in frames rather than HTTP requests. Both client and server can send frames at any time — this is what makes WebSocket full-duplex.
WebSocket vs Plain HTTP vs Server-Sent Events #
Before choosing WebSocket, understand when it’s appropriate:
| Aspect | HTTP Polling | Server-Sent Events | WebSocket |
|---|---|---|---|
| Direction | Client → Server | Server → Client only | Bidirectional (full-duplex) |
| Connection | Opened-closed per request | Persistent, one-way | Persistent, two-way |
| Overhead | High (HTTP headers per request) | Low | Minimal |
| Best for | Infrequently changing data | Notifications, news feeds | Chat, games, collaboration |
| Browser support | Universal | Good (except IE) | Very good |
| Proxy friendly | Very good | Good | Sometimes problematic |
USE WebSocket if:
✓ You need bidirectional real-time communication (chat, collaboration)
✓ The server needs to push data without being asked by the client
✓ Latency is critical (gaming, trading)
✓ Many frequent small updates
DON'T use WebSocket if:
✗ You only need one-way server push → use SSE
✗ Data changes rarely → HTTP polling is enough
✗ Regular stateless interaction → HTTP/REST is simpler
WebSocket with Ktor #
Ktor is the most idiomatic Kotlin-native framework for WebSocket. It uses coroutines natively, so it handles thousands of simultaneous connections efficiently.
Dependency Setup #
// build.gradle.kts
dependencies {
implementation("io.ktor:ktor-server-core:2.3.9")
implementation("io.ktor:ktor-server-netty:2.3.9")
implementation("io.ktor:ktor-server-websockets:2.3.9")
implementation("io.ktor:ktor-server-content-negotiation:2.3.9")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.9")
implementation("ch.qos.logback:logback-classic:1.5.3")
}
Basic WebSocket Server #
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import java.time.Duration
fun main() {
embeddedServer(Netty, port = 8080) {
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15) // send PING every 15 seconds
timeout = Duration.ofSeconds(15) // close the connection if no response in 15 seconds
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket("/ws") {
println("Client connected: ${call.request.local.remoteAddress}")
// Send a welcome message
send("Welcome to the WebSocket server!")
// Receive and process frames from the client
try {
for (frame in incoming) {
when (frame) {
is Frame.Text -> {
val message = frame.readText()
println("Received: $message")
// Echo back to the client
send("Echo: $message")
}
is Frame.Binary -> {
val bytes = frame.readBytes()
println("Binary frame: ${bytes.size} bytes")
}
is Frame.Ping -> {
// Ktor handles PONG automatically
}
is Frame.Close -> {
val reason = frame.readReason()
println("Client closed the connection: ${reason?.message}")
break
}
else -> {}
}
}
} catch (e: Exception) {
println("Connection lost: ${e.message}")
} finally {
println("Session closed")
}
}
}
}.start(wait = true)
}
Broadcast Chat Server — Managing Many Connections #
The most common WebSocket use case is chat or notifications that need to be sent to all connected clients. The key is storing references to all active sessions in a thread-safe way.
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
// Chat message model
data class ChatMessage(
val sender: String,
val content: String,
val time: String = java.time.LocalTime.now().toString()
)
// Centralized session management
class SessionManager {
private val activeSessions = ConcurrentHashMap<String, DefaultWebSocketSession>()
private val mutex = Mutex()
private val idCounter = AtomicInteger(0)
fun createId(): String = "user-${idCounter.incrementAndGet()}"
suspend fun add(id: String, session: DefaultWebSocketSession) {
mutex.withLock { activeSessions[id] = session }
println("[$id] Joined. Total sessions: ${activeSessions.size}")
}
suspend fun remove(id: String) {
mutex.withLock { activeSessions.remove(id) }
println("[$id] Left. Total sessions: ${activeSessions.size}")
}
suspend fun broadcast(message: String, except: String? = null) {
val currentSessions = mutex.withLock { activeSessions.toMap() }
currentSessions.forEach { (id, session) ->
if (id != except) {
runCatching { session.send(Frame.Text(message)) }
.onFailure { println("Failed to send to $id: ${it.message}") }
}
}
}
suspend fun sendTo(id: String, message: String) {
activeSessions[id]?.send(Frame.Text(message))
}
fun listUsers(): List<String> = activeSessions.keys.toList()
}
fun main() {
val manager = SessionManager()
embeddedServer(Netty, port = 8080) {
install(WebSockets) {
pingPeriod = Duration.ofSeconds(30)
timeout = Duration.ofSeconds(30)
}
routing {
webSocket("/chat") {
val sessionId = manager.createId()
manager.add(sessionId, this)
// Notify all other users
manager.broadcast("""{"type":"system","message":"$sessionId joined"}""", except = sessionId)
send("""{"type":"system","message":"You are connected as $sessionId"}""")
send("""{"type":"system","message":"Active users: ${manager.listUsers().joinToString(", ")}"}""")
try {
for (frame in incoming) {
if (frame !is Frame.Text) continue
val text = frame.readText()
println("[$sessionId] $text")
// Format the message to send to everyone
val messageJson = """{"type":"message","sender":"$sessionId","content":"${text.replace("\"", "\\\"")}","time":"${java.time.LocalTime.now()}"}"""
// Broadcast to everyone including the sender
manager.broadcast(messageJson)
}
} catch (e: Exception) {
println("[$sessionId] Error: ${e.message}")
} finally {
manager.remove(sessionId)
manager.broadcast("""{"type":"system","message":"$sessionId left the chat"}""")
}
}
// Endpoint to view server statistics
webSocket("/admin") {
send("""{"active_users": ${manager.listUsers().size}, "list": ${manager.listUsers()}}""")
close(CloseReason(CloseReason.Codes.NORMAL, "Info sent"))
}
}
}.start(wait = true)
}
WebSocket Client from Kotlin #
Ktor also provides a WebSocket client for communicating with a WebSocket server from Kotlin code (useful for testing or microservices):
// build.gradle.kts — add
// implementation("io.ktor:ktor-client-core:2.3.9")
// implementation("io.ktor:ktor-client-cio:2.3.9")
// implementation("io.ktor:ktor-client-websockets:2.3.9")
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
suspend fun main() {
val client = HttpClient(CIO) {
install(WebSockets)
}
client.webSocket(host = "localhost", port = 8080, path = "/chat") {
println("Connected to the server!")
// Send messages in a separate coroutine
val job = launch {
repeat(5) { i ->
send("Message from client #$i")
delay(1000)
}
send("Done!")
close(CloseReason(CloseReason.Codes.NORMAL, "Client finished"))
}
// Receive messages from the server
try {
for (frame in incoming) {
if (frame is Frame.Text) {
println("Server: ${frame.readText()}")
}
}
} catch (e: Exception) {
println("Connection closed: ${e.message}")
}
job.join()
}
client.close()
}
Heartbeat and Reconnect #
WebSocket connections can silently drop due to firewalls, proxies, or unstable networks. It’s important to implement a heartbeat mechanism:
// On the server side — Ktor handles this automatically with pingPeriod
install(WebSockets) {
pingPeriod = Duration.ofSeconds(30) // server sends PING every 30 seconds
timeout = Duration.ofSeconds(30) // close if no PONG within 30 seconds
}
// On the browser client side (JavaScript) — reference example
// const ws = new WebSocket('ws://localhost:8080/ws')
// ws.onclose = () => setTimeout(() => reconnect(), 3000) // auto-reconnect
For a Kotlin client that needs automatic reconnection:
suspend fun connectWithReconnect(
url: String,
onMessage: suspend (String) -> Unit,
maxAttempts: Int = 5
) {
val client = HttpClient(CIO) { install(WebSockets) }
var attempts = 0
while (attempts < maxAttempts) {
runCatching {
client.webSocket(url) {
attempts = 0 // reset the counter when connected successfully
println("Connected to $url")
for (frame in incoming) {
if (frame is Frame.Text) {
onMessage(frame.readText())
}
}
}
}.onFailure { e ->
attempts++
val delayMs = (attempts * 2000L).coerceAtMost(30_000L)
println("Connection failed ($attempts/$maxAttempts): ${e.message}. Retrying in ${delayMs}ms...")
delay(delayMs)
}
}
client.close()
println("Giving up after $maxAttempts attempts")
}
WebSocket Authentication #
WebSocket has no built-in authentication mechanism — you need to add your own. There are two common approaches:
routing {
// Approach 1: Token in a query parameter (simple but the token is visible in the URL)
webSocket("/ws") {
val token = call.request.queryParameters["token"]
?: run {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Token required"))
return@webSocket
}
val user = validateToken(token)
?: run {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Invalid token"))
return@webSocket
}
println("${user.name} connected")
// Continue...
}
// Approach 2: Token in the first message after connection (more secure)
webSocket("/ws/secure") {
// Request authentication in the first message
send("""{"type":"auth","message":"Send your authentication token"}""")
val firstFrame = incoming.receive()
if (firstFrame !is Frame.Text) {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Invalid frame"))
return@webSocket
}
val token = firstFrame.readText()
val user = validateToken(token)
?: run {
send("""{"type":"error","message":"Invalid token"}""")
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Authentication failed"))
return@webSocket
}
send("""{"type":"auth_ok","message":"Welcome, ${user.name}!"}""")
// Continue the authenticated session...
}
}
WebSocket with Spring Boot #
Spring Boot provides enterprise-grade WebSocket support with STOMP (Simple Text Oriented Messaging Protocol):
// build.gradle.kts
// implementation("org.springframework.boot:spring-boot-starter-websocket")
import org.springframework.context.annotation.Configuration
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.SendTo
import org.springframework.stereotype.Controller
import org.springframework.web.socket.config.annotation.*
// WebSocket + STOMP configuration
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {
override fun configureMessageBroker(config: MessageBrokerRegistry) {
config.enableSimpleBroker("/topic", "/queue") // subscribe prefixes
config.setApplicationDestinationPrefixes("/app") // send prefix
}
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS() // fall back to polling if WebSocket isn't available
}
}
// Message models
data class IncomingMessage(val content: String)
data class OutgoingMessage(val sender: String, val content: String)
// Message controller
@Controller
class ChatController {
@MessageMapping("/chat.send") // client sends to /app/chat.send
@SendTo("/topic/messages") // server broadcasts to /topic/messages
fun handleMessage(message: IncomingMessage): OutgoingMessage {
return OutgoingMessage("Server", "Received: ${message.content}")
}
}
Spring Boot + STOMP fits when you’re already using Spring and need enterprise features like authentication integrated with Spring Security, external message brokers (RabbitMQ/ActiveMQ), and complex subscription management.
WebSocket with Vert.x #
Vert.x uses a reactive event loop model that’s very efficient for extremely large numbers of simultaneous connections:
// build.gradle.kts
// implementation("io.vertx:vertx-web:4.5.1")
import io.vertx.core.AbstractVerticle
import io.vertx.core.Vertx
import io.vertx.ext.web.Router
import io.vertx.ext.web.handler.StaticHandler
class WebSocketVerticle : AbstractVerticle() {
override fun start() {
val router = Router.router(vertx)
// Static route for the UI
router.route("/static/*").handler(StaticHandler.create())
// WebSocket route
val server = vertx.createHttpServer()
server.webSocketHandler { ws ->
println("Client connected: ${ws.remoteAddress()}")
ws.textMessageHandler { message ->
println("Received: $message")
ws.writeTextMessage("Echo: $message")
}
ws.closeHandler {
println("Client ${ws.remoteAddress()} disconnected")
}
ws.exceptionHandler { e ->
println("Error: ${e.message}")
}
}
server.requestHandler(router)
.listen(8080) { result ->
if (result.succeeded()) println("Vert.x WebSocket server on port 8080")
else println("Failed: ${result.cause().message}")
}
}
}
fun main() {
Vertx.vertx().deployVerticle(WebSocketVerticle())
}
Summary #
- WebSocket for real-time bidirectional communication — use WebSocket when the server needs to push data to the client without being asked, and the client also needs to actively send data to the server (chat, games, collaboration).
- SSE for one-way server push — if you only need server-to-client notifications (news feeds, progress bars), Server-Sent Events are simpler and more proxy-friendly than WebSocket.
- Ktor is the primary choice for Kotlin — native coroutines, idiomatic, lightweight. Suitable for all scales from prototype to production.
ConcurrentHashMapfor session management — store session references in aConcurrentHashMapfor broadcasting. Always wrap broadcast operations withrunCatching— one errored session must not stop delivery to others.- Enable
pingPeriodin Ktor — without a heartbeat, idle connections can be dropped by firewalls/proxies without notification. Set a reasonablepingPeriodandtimeout(30 seconds is common).- Authentication is mandatory — WebSocket has no built-in authentication. Validate via a query parameter or the first message after connection. Close immediately with
CloseReason.VIOLATED_POLICYif invalid.- Spring Boot + STOMP for enterprise environments — if you’re already in the Spring ecosystem and need integrated authentication, message brokers, and subscription management, STOMP on top of WebSocket is a mature choice.
- Implement reconnection on the client — WebSocket connections can drop due to network issues. A good client always tries to reconnect with exponential backoff.