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: CLOSE

After 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:

AspectHTTP PollingServer-Sent EventsWebSocket
DirectionClient → ServerServer → Client onlyBidirectional (full-duplex)
ConnectionOpened-closed per requestPersistent, one-wayPersistent, two-way
OverheadHigh (HTTP headers per request)LowMinimal
Best forInfrequently changing dataNotifications, news feedsChat, games, collaboration
Browser supportUniversalGood (except IE)Very good
Proxy friendlyVery goodGoodSometimes 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.
  • ConcurrentHashMap for session management — store session references in a ConcurrentHashMap for broadcasting. Always wrap broadcast operations with runCatching — one errored session must not stop delivery to others.
  • Enable pingPeriod in Ktor — without a heartbeat, idle connections can be dropped by firewalls/proxies without notification. Set a reasonable pingPeriod and timeout (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_POLICY if 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.

← Previous: Sockets   Next: Web Server →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact