androidengineers.Book a session

Network Architecture

WebSockets & Real-time Channels

article20 minHard

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)

FeatureWebSocketSSE
DirectionFull-duplex (both sides)Server → Client only
ProtocolCustom (ws://, wss://)HTTP (regular https://)
ReconnectionManualBuilt-in (browser/EventSource)
Binary supportYesNo (text only)
Firewall/proxy compatibilitySometimes blockedWorks everywhere HTTP does
Overhead per messageLow (framing only)Higher (HTTP streaming)
Best forChat, gaming, collaborationFeeds, 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

ConceptKey Point
WebSocket upgradeSingle HTTP request → persistent full-duplex TCP channel
callbackFlowWraps WebSocketListener into a cold Flow
ReconnectionExponential backoff with jitter; cap at 60 seconds
Backoff formulamin(1000 * 2^attempt + jitter, 60_000)
HeartbeatpingInterval on OkHttp + application-level pong timeout
BackgroundingDisconnect on ON_STOP, reconnect on ON_START
SerializationJSON for simplicity; Protobuf for throughput
SSE trade-offSSE for server-only push; WebSocket for bidirectional

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
WebSockets & Real-time Channels | Android System Design | Android Engineers