WebSocket Protocol
HTTP is request-response: the client initiates every exchange and the server cannot push data unprompted. WebSocket upgrades a single HTTP connection to a full-duplex bidirectional channel — both sides can send messages at any time.
The handshake:
Client → Server:
GET /chat HTTP/1.1
Host: ws.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server → Client:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After the 101 response, the TCP connection stays open. Either side can send frames at any time.
OkHttp WebSocket API
class ChatWebSocketClient @Inject constructor(
private val okHttpClient: OkHttpClient,
private val tokenStore: TokenStore
) {
private var webSocket: WebSocket? = null
fun connect(roomId: String): Flow<WebSocketEvent> = callbackFlow {
val request = Request.Builder()
.url("wss://ws.example.com/chat/$roomId")
.header("Authorization", "Bearer ${tokenStore.getAccessToken()}")
.build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
this@ChatWebSocketClient.webSocket = webSocket
trySend(WebSocketEvent.Connected)
}
override fun onMessage(webSocket: WebSocket, text: String) {
trySend(WebSocketEvent.MessageReceived(text))
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
trySend(WebSocketEvent.BinaryReceived(bytes))
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(code, reason)
trySend(WebSocketEvent.Closing(code, reason))
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
this@ChatWebSocketClient.webSocket = null
trySend(WebSocketEvent.Disconnected(code, reason))
channel.close()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
this@ChatWebSocketClient.webSocket = null
trySend(WebSocketEvent.Error(t))
channel.close(t)
}
}
webSocket = okHttpClient.newWebSocket(request, listener)
awaitClose {
webSocket?.close(1000, "Client closing")
webSocket = null
}
}
fun send(message: String): Boolean {
return webSocket?.send(message) ?: false
}
fun disconnect() {
webSocket?.close(1000, "User disconnected")
}
}
sealed class WebSocketEvent {
object Connected : WebSocketEvent()
data class MessageReceived(val text: String) : WebSocketEvent()
data class BinaryReceived(val bytes: ByteString) : WebSocketEvent()
data class Closing(val code: Int, val reason: String) : WebSocketEvent()
data class Disconnected(val code: Int, val reason: String) : WebSocketEvent()
data class Error(val throwable: Throwable) : WebSocketEvent()
}
Reconnection with Exponential Backoff
WebSocket connections drop — network changes, server restarts, idle timeouts. A robust client reconnects automatically.
class ReconnectingWebSocketManager @Inject constructor(
private val client: ChatWebSocketClient,
private val scope: CoroutineScope
) {
private val _events = MutableSharedFlow<ChatMessage>(replay = 0, extraBufferCapacity = 64)
val messages: SharedFlow<ChatMessage> = _events.asSharedFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
fun connect(roomId: String) {
scope.launch {
var attempt = 0
while (isActive) {
_connectionState.value = if (attempt == 0) {
ConnectionState.Connecting
} else {
ConnectionState.Reconnecting(attempt)
}
try {
client.connect(roomId)
.onEach { event ->
when (event) {
is WebSocketEvent.Connected -> {
attempt = 0
_connectionState.value = ConnectionState.Connected
}
is WebSocketEvent.MessageReceived -> {
val message = parseMessage(event.text)
_events.emit(message)
}
is WebSocketEvent.Disconnected -> {
_connectionState.value = ConnectionState.Disconnected
}
else -> Unit
}
}
.collect()
// Flow completed normally (server closed cleanly)
break
} catch (e: CancellationException) {
throw e // always rethrow CancellationException
} catch (e: Exception) {
attempt++
val delayMs = exponentialBackoff(attempt)
_connectionState.value = ConnectionState.WaitingToReconnect(delayMs)
delay(delayMs)
}
}
}
}
private fun exponentialBackoff(attempt: Int): Long {
val base = 1000L * (2.0.pow(minOf(attempt - 1, 6))).toLong()
val jitter = (Math.random() * 1000).toLong()
return minOf(base + jitter, 60_000L)
}
}
sealed class ConnectionState {
object Disconnected : ConnectionState()
object Connecting : ConnectionState()
object Connected : ConnectionState()
data class Reconnecting(val attempt: Int) : ConnectionState()
data class WaitingToReconnect(val delayMs: Long) : ConnectionState()
}
Message Serialization
JSON (Moshi)
data class ChatMessage(
val id: String,
val roomId: String,
val senderId: String,
val content: String,
val timestamp: Long,
val type: MessageType
)
enum class MessageType { TEXT, IMAGE, SYSTEM }
data class WebSocketFrame(
val event: String,
val payload: JsonElement
)
class MessageSerializer @Inject constructor() {
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val frameAdapter = moshi.adapter(WebSocketFrame::class.java)
private val messageAdapter = moshi.adapter(ChatMessage::class.java)
fun serialize(message: ChatMessage): String {
val frame = WebSocketFrame(
event = "message",
payload = messageAdapter.toJsonValue(message) as JsonElement
)
return frameAdapter.toJson(frame)
}
fun deserialize(json: String): ChatMessage? {
return try {
val frame = frameAdapter.fromJson(json) ?: return null
if (frame.event != "message") return null
messageAdapter.fromJsonValue(frame.payload)
} catch (e: Exception) {
null
}
}
}
Protocol Buffers (binary, more efficient)
// chat.proto
syntax = "proto3";
message ChatMessage {
string id = 1;
string room_id = 2;
string sender_id = 3;
string content = 4;
int64 timestamp = 5;
}
// Send binary protobuf over WebSocket
fun send(message: ChatMessageProto): Boolean {
val bytes = message.toByteArray().toByteString()
return webSocket?.send(bytes) ?: false
}
// Receive binary
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
val message = ChatMessageProto.parseFrom(bytes.toByteArray())
trySend(WebSocketEvent.BinaryReceived(bytes))
}
Heartbeat / Ping-Pong
WebSocket has a built-in ping/pong mechanism. OkHttp handles application-layer pings automatically, but you can implement application-level heartbeats for additional reliability:
class HeartbeatManager(
private val sendPing: () -> Boolean,
private val onTimeout: () -> Unit,
private val scope: CoroutineScope
) {
private val PING_INTERVAL = 30_000L
private val PONG_TIMEOUT = 10_000L
private var pingJob: Job? = null
fun start() {
pingJob?.cancel()
pingJob = scope.launch {
while (isActive) {
delay(PING_INTERVAL)
sendPing()
// Wait for pong — if connection is alive, pong arrives quickly
val pongReceived = withTimeoutOrNull(PONG_TIMEOUT) {
awaitPong()
}
if (pongReceived == null) {
onTimeout() // connection is dead, trigger reconnect
break
}
}
}
}
fun stop() { pingJob?.cancel() }
}
// OkHttp also supports built-in ping at the protocol level:
val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS) // OkHttp sends WebSocket pings automatically
.build()
Handling Backgrounding
WebSockets should disconnect when the app goes to the background to conserve battery and avoid keepalive traffic. Reconnect when the app returns to the foreground.
@HiltViewModel
class ChatViewModel @Inject constructor(
private val wsManager: ReconnectingWebSocketManager
) : ViewModel() {
private var connectJob: Job? = null
// Called when screen becomes visible
fun onStart(roomId: String) {
connectJob = viewModelScope.launch {
wsManager.connect(roomId)
}
}
// Called when screen becomes invisible
fun onStop() {
connectJob?.cancel()
wsManager.disconnect()
}
override fun onCleared() {
wsManager.disconnect()
}
}
// In the Compose UI
@Composable
fun ChatScreen(roomId: String, viewModel: ChatViewModel = hiltViewModel()) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, roomId) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> viewModel.onStart(roomId)
Lifecycle.Event.ON_STOP -> viewModel.onStop()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
WebSocket vs Server-Sent Events (SSE)
| Feature | WebSocket | SSE |
|---|---|---|
| Direction | Full-duplex (both sides) | Server → Client only |
| Protocol | Custom (ws://, wss://) | HTTP (regular https://) |
| Reconnection | Manual | Built-in (browser/EventSource) |
| Binary support | Yes | No (text only) |
| Firewall/proxy compatibility | Sometimes blocked | Works everywhere HTTP does |
| Overhead per message | Low (framing only) | Higher (HTTP streaming) |
| Best for | Chat, gaming, collaboration | Feeds, notifications, price tickers |
Use SSE when you only need server-to-client push and want simpler infrastructure. Use WebSocket when the client must also send frequent messages.
Practical Chat Client Example
@HiltViewModel
class ChatViewModel @Inject constructor(
private val wsManager: ReconnectingWebSocketManager,
private val messageSerializer: MessageSerializer
) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
wsManager.connectionState.collect { state ->
_uiState.update { it.copy(connectionState = state) }
}
}
viewModelScope.launch {
wsManager.messages.collect { message ->
_uiState.update { it.copy(
messages = it.messages + message
)}
}
}
}
fun sendMessage(content: String) {
val message = ChatMessage(
id = UUID.randomUUID().toString(),
roomId = currentRoomId,
senderId = currentUserId,
content = content,
timestamp = System.currentTimeMillis(),
type = MessageType.TEXT
)
// Optimistic update
_uiState.update { it.copy(messages = it.messages + message) }
val json = messageSerializer.serialize(message)
val sent = wsManager.send(json)
if (!sent) {
// Remove optimistic message and show error
_uiState.update { it.copy(
messages = it.messages.filter { m -> m.id != message.id },
error = "Failed to send message"
)}
}
}
}
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
val connectionState: ConnectionState = ConnectionState.Disconnected,
val error: String? = null
)
Key Takeaways
| Concept | Key Point |
|---|---|
| WebSocket upgrade | Single HTTP request → persistent full-duplex TCP channel |
callbackFlow | Wraps WebSocketListener into a cold Flow |
| Reconnection | Exponential backoff with jitter; cap at 60 seconds |
| Backoff formula | min(1000 * 2^attempt + jitter, 60_000) |
| Heartbeat | pingInterval on OkHttp + application-level pong timeout |
| Backgrounding | Disconnect on ON_STOP, reconnect on ON_START |
| Serialization | JSON for simplicity; Protobuf for throughput |
| SSE trade-off | SSE for server-only push; WebSocket for bidirectional |