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.

One connection, two directions: once a WebSocket handshake completes, there's no more "requesting" — both the app and the server can write to the same open pipe whenever they have something to say.

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 } }
}
Note: These are correct, working Kotlin/OkHttp patterns, but this website's own project doesn't run a live Android Studio build or a real WebSocket server, so nothing here connects or executes on the page itself.

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.

Up next: Real-time feeds are one kind of background work — but TrailLog also needs work that keeps running even when the app isn't open at all, like syncing unsaved trails. WorkManager is built exactly for that.