skip to content

In the Ktor client, how do you open a WebSocket connection and exchange frames?

level: middleimportance: nice to knowfreq 32%

answer

  1. Plugin, then a session-scoped call
  2. The block's receiver is the session
  3. Traffic is frames, not request/response
  4. Lifetime ends with the block
  5. A converter enables typed send and receive

basics

~10 s

Install the WebSockets plugin on the HttpClient, then call client.webSocket("wss://host/path") { ... }. Inside the block you send with send(Frame.Text(...)) and read from incoming; the session closes when the block returns.

solid answer

~40 s

Add `ktor-client-websockets`, `install(WebSockets)` on the client, and use `client.webSocket("wss://host/path") { ... }`. The block's receiver is a `DefaultClientWebSocketSession`: you write with `send(Frame.Text("hello"))` or `send(Frame.Binary(...))`, and read with `for (frame in incoming) { if (frame is Frame.Text) println(frame.readText()) }`. Protocol `Ping`/`Pong` frames are handled by the plugin, so application code normally sees only text and binary. The session's lifetime is the block: when it returns, the connection closes, so long-lived work must stay inside — or use `client.webSocketSession(...)`, which hands back a session you hold and close yourself. To exchange typed messages instead of raw frames, set `contentConverter = KotlinxWebsocketSerializationConverter(Json)` in the plugin config and use `sendSerialized(obj)` / `receiveDeserialized<T>()`. Note that WebSocket support is engine-dependent.

code

kotlin · 15 lines
kotlin
val client = HttpClient(CIO) {
    install(WebSockets)
}

client.webSocket("wss://example.com/chat") {
    send(Frame.Text("hello"))
    for (frame in incoming) {
        when (frame) {
            is Frame.Text -> println(frame.readText())
            is Frame.Binary -> println("binary: ${frame.readBytes().size} bytes")
            else -> Unit
        }
    }
    close(CloseReason(CloseReason.Codes.NORMAL, "done"))
}

go deeper

for a junior

Recall the three steps: install the WebSockets plugin, call client.webSocket(url) { }, then send frames and iterate incoming. Know that the connection ends when the block ends.

for a middle

Explain the frame model — Text, Binary, Close, Ping/Pong handled by the plugin — the block-scoped session lifetime, and how a content converter turns frames into typed messages.

for a senior

Show operational awareness: engine support must be verified, closeReason distinguishes clean from abnormal termination, and a long-lived feed needs webSocketSession with an owner responsible for closing it.

for a principal

Own the choice itself: when a persistent bidirectional channel is justified over polling or server-sent events, given reconnection, backpressure and the load a long-lived connection per client places on the fleet.

## Wiring WebSocket support in the Ktor client is a plugin, and it needs its own artifact, `ktor-client-websockets`, alongside an engine that supports the protocol — this is one of the capabilities that varies between engines, so check before committing to one. val client = HttpClient(CIO) { install(WebSockets) } Installing the plugin adds the client-side handshake and the frame machinery; it does not change how ordinary HTTP requests behave. ## The webSocket block The primary entry point is a suspend function taking a block: client.webSocket("wss://example.com/chat") { send(Frame.Text("hello")) for (frame in incoming) { if (frame is Frame.Text) println(frame.readText()) } } The receiver inside the block is a `DefaultClientWebSocketSession`. Two things follow from this shape: - The **connection's lifetime is the block**. When the block returns, the session closes. Anything that must keep receiving has to remain inside; storing the session in a field and using it after the block has returned gives you a closed connection. - The whole thing is a suspend call, so the caller waits for the conversation to finish. There are overloads taking `method`, `host`, `port` and `path` separately as well as the single URL form, and a `wss` variant for explicit secure connections. ## Frames WebSocket traffic is frames, not requests. The session exposes `incoming` (frames arriving from the peer) and `outgoing` (frames to send), with `send(...)` as the convenient wrapper over `outgoing`. Frame types: - `Frame.Text` — read the payload with `frame.readText()`. - `Frame.Binary` — read with `frame.readBytes()`. - `Frame.Close` — the peer is closing. - `Frame.Ping` / `Frame.Pong` — protocol keep-alive frames, answered by the plugin so application code does not have to handle them. Since `incoming` delivers frames as they arrive and either side may send at any time, a WebSocket client is a two-way conversation rather than a request/response exchange — which is precisely why the API looks nothing like `client.get(...)`. ## Closing Either side may end the conversation. You close explicitly with a reason: close(CloseReason(CloseReason.Codes.NORMAL, "done")) When the peer closes, the loop over `incoming` ends. The session exposes `closeReason`, which resolves to the `CloseReason` the connection ended with — useful for telling an orderly shutdown apart from a peer that vanished, and worth logging in any real client. ## Sessions outside a block When a connection must outlive one function — a chat screen, a market-data feed held for the life of a component — use: val session = client.webSocketSession("wss://example.com/chat") This returns the session directly, so you own its lifetime: keep it, read from it, and close it yourself when the owning component goes away. The trade is exactly the usual one — the block form cannot leak the connection, the session form can. ## Typed messages Raw frames get tedious when both sides speak JSON. Configure a converter on the plugin: install(WebSockets) { contentConverter = KotlinxWebsocketSerializationConverter(Json) } Then the session offers `sendSerialized(message)` and `receiveDeserialized<Message>()`, which encode and decode through the converter instead of you calling `readText()` and parsing by hand. This is separate from the `ContentNegotiation` plugin used for HTTP bodies — WebSocket serialization is configured on the WebSockets plugin itself. ## Why this shows up in interviews The interesting part is not the syntax; it is that the same library, the same plugin model and the same session API cover both the server and the client sides of a WebSocket, and in a Kotlin Multiplatform project the client half compiles for Android and iOS too. A candidate who can describe the frame loop, the block-scoped lifetime and the engine-support caveat has clearly used it rather than read about it. ## Common mistakes Forgetting the `ktor-client-websockets` artifact or the `install(WebSockets)` call; picking an engine without WebSocket support and discovering it at runtime; escaping the session out of the `webSocket { }` block and then using it; ignoring `closeReason` so abnormal closes look identical to clean ones; and hand-parsing JSON in every frame when a `contentConverter` would do it.

  • What is the difference between client.webSocket { } and client.webSocketSession() in Ktor?
    webSocket { } scopes the connection to the block — it closes when the block returns, so the session cannot outlive it or be leaked. webSocketSession() returns the session object directly, letting a connection live as long as the component holding it, at the cost of your having to close it yourself.
  • How do you exchange typed objects over a Ktor client WebSocket instead of raw frames?
    Set contentConverter on the WebSockets plugin — for example KotlinxWebsocketSerializationConverter(Json) — then use sendSerialized(obj) and receiveDeserialized<T>() on the session. This is configured on the WebSockets plugin itself, separately from the ContentNegotiation plugin that handles ordinary HTTP bodies.
  • How does a Ktor client learn why a WebSocket connection ended?
    The session exposes closeReason, which resolves to the CloseReason the connection ended with, and a Frame.Close may arrive on incoming before the loop finishes. Logging that reason distinguishes an orderly shutdown from a peer that disappeared, which otherwise look the same from the loop's point of view.

saying these in an interview costs you the question

  • Expects request/response semantics on a WebSocket
  • Uses the session after the webSocket block returns
  • Assumes every engine supports WebSockets
  • Hand-answers Ping frames the plugin already handles
  • Ignores closeReason and treats all closes alike

context