WebSockets
Every networking lesson so far has been built around a request-then-response pattern: TrailLog asks the server for something with Retrofit, waits, and gets an answer back. That model works beautifully for loading a trail list or saving a new hike, but it has a hard limit — each request is a one-off round trip that closes the moment the response arrives. If the server has something new to tell the app without being asked, plain HTTP has no way to push it. That's the gap WebSockets fill.
Why a Regular HTTP Request Isn't Enough
A WebSocket is a connection that both sides keep open, and either side can send a message across it at any time — no new request needed. Picture a feature TrailLog doesn't strictly need today but plausibly could: a group hike screen where several friends share their live location while hiking the same trail together. With plain HTTP you'd have to poll — ask the server "any updates?" every few seconds, most of the time getting nothing back and wasting battery and data. With a WebSocket, the server simply pushes each friend's new coordinates the instant they arrive, and the app updates the shared map in real time.
OkHttp's Built-in WebSocket Support
You don't need a separate library for this — the same OkHttpClient you already build
for Retrofit in OkHttp & Interceptors knows how to
open and manage WebSocket connections directly. You hand it a request and a listener, and OkHttp
takes care of the handshake and the connection's lifecycle underneath.
Opening a WebSocket for the group hike feed
val request = Request.Builder()
.url("wss://api.traillog.example.com/live/group-hike/42")
.build()
val webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// connection is live — safe to start sending messages now
}
override fun onMessage(webSocket: WebSocket, text: String) {
// a friend's location update arrived as a JSON string
val update = json.decodeFromString<LocationUpdate>(text)
// hand it off to the UI layer to plot on the shared map
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, null) // acknowledge a clean shutdown from the server
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
// connection dropped unexpectedly — this is where reconnection logic belongs
}
})
onOpen fires once the handshake succeeds, onMessage fires every time the
server pushes new data, onClosing fires when either side starts a graceful shutdown,
and onFailure fires for anything that goes wrong — a dropped network, a server crash,
a bad handshake. Real WebSocket code has to handle all four; ignoring onFailure is how
an app ends up silently stuck on stale data.
Sending a Message Over an Open Connection
The connection works both ways. Once onOpen has fired, TrailLog can push its own
location up to the server the same way it receives updates from friends:
fun sendMyLocation(latitude: Double, longitude: Double) {
val update = LocationUpdate(latitude, longitude)
val json = json.encodeToString(update)
webSocket.send(json)
}
webSocket.send() queues the message on the same open pipe — no new connection, no new
handshake, just a write straight to the socket that's already there.
Bridging Callbacks Into a Flow with callbackFlow
WebSocketListener is a callback API — exactly the kind of older, event-driven shape
that doesn't fit naturally with the suspend-function and Flow-based code the rest of
this track builds, as covered in
Flow vs LiveData. Kotlin's callbackFlow { }
builder exists specifically to bridge that gap: it lets you wrap a callback API in a
Flow that the rest of your app can collect like any other stream of values.
Wrapping the group hike feed as a Flow
fun observeGroupHikeFeed(request: Request): Flow<LocationUpdate> = callbackFlow {
val webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
val update = json.decodeFromString<LocationUpdate>(text)
trySend(update) // push the value into the Flow
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
close(t) // close the Flow with the underlying error
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
close() // close the Flow cleanly
}
})
awaitClose {
// runs when the collecting coroutine is cancelled — always clean up the connection
webSocket.close(1000, "no longer observing")
}
}
trySend() forwards each incoming update into the Flow, close() ends the
Flow (with or without an error), and the awaitClose { } block is the crucial part —
it's guaranteed to run when the collector goes away, so the WebSocket always gets shut down
instead of leaking a live connection in the background. From the ViewModel's side, this now reads
like any other Flow:
viewModelScope.launch {
groupHikeRepository.observeGroupHikeFeed(request)
.collect { update -> _liveLocations.update { it + update } }
}
When WebSockets Are — and Aren't — Worth the Complexity
A WebSocket is genuinely more moving parts than a Retrofit call: you own the connection's entire lifecycle, you have to detect and recover from drops with your own reconnection logic, and you have to think about what happens to queued messages while the connection is down. None of that is free. Reach for a WebSocket only when the feature truly needs bidirectional, real-time updates — like a shared live location feed. For anything that's really just "fetch this, then fetch that again later," the request/response model from Retrofit + Coroutines is simpler, more resilient, and almost always the better default.