Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 076 / 100

WebSockets in Android: a practical Android & Kotlin guide

Real-time features.

Expona Admin8 min read

Introduction

Real-time features are table stakes in modern mobile apps—from live notifications and collaborative editing to trading feeds and ride-hailing location updates. WebSockets in Android: a practical Android & Kotlin guide will walk you through building a production-ready WebSocket client that handles connection lifecycle, automatic reconnection, and clean resource management. We'll use OkHttp's battle-tested WebSocket implementation and expose messages through Kotlin Flow for seamless integration with your app's architecture.

Why WebSockets matter in Android

HTTP polling burns battery and data. Server-sent events don't work well for bidirectional streams. WebSockets give you persistent, low-latency two-way communication—essential when your users expect live updates without noticeable delay. On Android, though, network conditions change constantly: users move between WiFi and mobile, apps are backgrounded, and connections drop. A naive WebSocket implementation will fail in production within hours.

OkHttp WebSocket client

OkHttp is the de facto HTTP client for Android. Its WebSocket support is production-grade and integrates neatly with your existing networking stack. Rather than reaching for a third-party library, we start with OkHttp's core WebSocketListener and Request API.

Here's a basic WebSocket listener implementation:

kotlin
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString

class WebSocketManager(private val okHttpClient: OkHttpClient) {
    private var webSocket: WebSocket? = null

    fun connect(url: String) {
        val request = Request.Builder()
            .url(url)
            .build()

        webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
                println("WebSocket opened")
            }

            override fun onMessage(webSocket: WebSocket, text: String) {
                println("Message received: $text")
            }

            override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
                println("Bytes received: ${bytes.hex()}")
            }

            override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
                webSocket.close(1000, null)
                println("WebSocket closing: $code $reason")
            }

            override fun onFailure(
                webSocket: WebSocket,
                t: Throwable,
                response: okhttp3.Response?
            ) {
                println("WebSocket failure: ${t.message}")
            }
        })
    }

    fun send(message: String) {
        webSocket?.send(message)
    }

    fun disconnect() {
        webSocket?.close(1000, "Client disconnect")
    }
}

This works for a demo, but in production you'll hit three problems immediately: no reconnection logic, no Flow-based message delivery, and no lifecycle awareness. Let's solve each one.

Reconnect with backoff

Network failures are inevitable. A dropped connection won't automatically reconnect—your app goes silent. Exponential backoff prevents hammering the server when it's down and gives it breathing room to recover. We implement this with a coroutine-based retry strategy:

kotlin
import kotlinx.coroutines.*
import kotlin.math.min

class WebSocketManager(
    private val okHttpClient: OkHttpClient,
    private val scope: CoroutineScope
) {
    private var webSocket: WebSocket? = null
    private var reconnectJob: Job? = null
    private val maxBackoffMs = 30_000L
    private var backoffMs = 1_000L

    fun connect(url: String) {
        reconnectJob = scope.launch {
            while (isActive) {
                try {
                    connectOnce(url)
                    backoffMs = 1_000L // Reset backoff on success
                    return@launch
                } catch (e: Exception) {
                    if (!isActive) return@launch
                    delay(backoffMs)
                    backoffMs = min(backoffMs * 2, maxBackoffMs)
                }
            }
        }
    }

    private suspend fun connectOnce(url: String) = suspendCancellableCoroutine { continuation ->
        val request = Request.Builder().url(url).build()

        webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
                continuation.resume(Unit)
            }

            override fun onFailure(
                webSocket: WebSocket,
                t: Throwable,
                response: okhttp3.Response?
            ) {
                continuation.resumeWithException(t)
            }

            // Handle onMessage and onClosing...
        })

        continuation.invokeOnCancellation {
            webSocket?.close(1000, null)
        }
    }

    fun disconnect() {
        reconnectJob?.cancel()
        webSocket?.close(1000, "Client disconnect")
    }
}

The key insight: backoffMs doubles on each failure, capped at 30 seconds. When a connection succeeds, we reset to 1 second. This handles transient network glitches (brief retry) while protecting the server during outages (progressively longer delays).

Expose messages as Flow

Coroutine Flow is how we bridge WebSocket callbacks into our app's reactive architecture. Instead of callback hell, messages become a clean stream you can collect, transform, and combine with other streams:

kotlin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow

sealed class WebSocketEvent {
    data class Message(val text: String) : WebSocketEvent()
    data class Error(val throwable: Throwable) : WebSocketEvent()
    object Connected : WebSocketEvent()
    object Disconnected : WebSocketEvent()
}

class WebSocketManager(
    private val okHttpClient: OkHttpClient,
    private val scope: CoroutineScope
) {
    private var webSocket: WebSocket? = null
    private var reconnectJob: Job? = null
    
    private val _events = MutableSharedFlow(
        replay = 0,
        extraBufferCapacity = 64
    )
    val events: Flow = _events

    private val maxBackoffMs = 30_000L
    private var backoffMs = 1_000L

    fun connect(url: String) {
        reconnectJob = scope.launch {
            while (isActive) {
                try {
                    connectOnce(url)
                    backoffMs = 1_000L
                    return@launch
                } catch (e: Exception) {
                    _events.emit(WebSocketEvent.Error(e))
                    if (!isActive) return@launch
                    delay(backoffMs)
                    backoffMs = min(backoffMs * 2, maxBackoffMs)
                }
            }
        }
    }

    private suspend fun connectOnce(url: String) = suspendCancellableCoroutine { continuation ->
        val request = Request.Builder().url(url).build()

        webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
                scope.launch { _events.emit(WebSocketEvent.Connected) }
                continuation.resume(Unit)
            }

            override fun onMessage(webSocket: WebSocket, text: String) {
                scope.launch { _events.emit(WebSocketEvent.Message(text)) }
            }

            override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
                scope.launch { _events.emit(WebSocketEvent.Disconnected) }
                webSocket.close(1000, null)
            }

            override fun onFailure(
                webSocket: WebSocket,
                t: Throwable,
                response: okhttp3.Response?
            ) {
                scope.launch { _events.emit(WebSocketEvent.Error(t)) }
                continuation.resumeWithException(t)
            }
        })

        continuation.invokeOnCancellation {
            webSocket?.close(1000, null)
        }
    }

    fun send(message: String) {
        webSocket?.send(message)
    }

    fun disconnect() {
        reconnectJob?.cancel()
        webSocket?.close(1000, "Client disconnect")
    }
}

Now your UI can collect and react to events naturally:

kotlin
// In your ViewModel or composable
viewModelScope.launch {
    websocketManager.events.collect { event ->
        when (event) {
            is WebSocketEvent.Message -> handleMessage(event.text)
            is WebSocketEvent.Connected -> updateUI("Connected")
            is WebSocketEvent.Error -> showError(event.throwable)
            is WebSocketEvent.Disconnected -> updateUI("Disconnected")
        }
    }
}

Close when the app is backgrounded

Backgrounded apps should not hold WebSocket connections. Battery drain, unnecessary data usage, and server resource waste all result from keeping a connection alive while the user is elsewhere. Lifecycle-aware management ensures we clean up properly:

kotlin
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner

class WebSocketLifecycleObserver(
    private val websocketManager: WebSocketManager
) : LifecycleEventObserver {

    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        when (event) {
            Lifecycle.Event.ON_RESUME -> {
                websocketManager.connect("wss://api.example.com/stream")
            }
            Lifecycle.Event.ON_PAUSE -> {
                websocketManager.disconnect()
            }
            else -> {}
        }
    }
}

// In your Activity or Fragment
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    lifecycle.addObserver(WebSocketLifecycleObserver(websocketManager))
}

This observer connects when your Activity/Fragment comes to the foreground and closes when it goes to the background. For multi-screen apps, track a reference count—only truly close when the last screen backgrounding occurs.

Alternatively, use Jetpack Compose lifecycle:

kotlin
@Composable
fun MyScreen(viewModel: MyViewModel) {
    val lifecycleOwner = LocalLifecycleOwner.current

    LaunchedEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_RESUME -> viewModel.connectWebSocket()
                Lifecycle.Event.ON_PAUSE -> viewModel.disconnectWebSocket()
                else -> {}
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    // UI here
}

Common mistakes

  • Ignoring onClosing: Always consume and close the WebSocket in onClosing. Ignoring this callback leaves connections in a half-open state.
  • Reconnecting without backoff: Immediate reconnection hammers the server and wastes battery. Always implement exponential backoff with a cap.
  • Blocking the WebSocket thread: OkHttp calls your callbacks on a background thread. Don't do heavy work there—emit to Flow and handle on the main thread or coroutine scope.
  • Leaking scope: If you pass GlobalScope to the manager, it will outlive your Activity. Always use viewModelScope or lifecycleScope so cancellation propagates correctly.
  • Keeping connections on backgrounded apps: Drains battery and wastes data. Hook into Lifecycle and close on ON_PAUSE.
  • No message ordering guarantees: MutableSharedFlow is unordered across collectors. If order is critical, use a Channel with a specific ordering discipline instead.

Key takeaways

  • OkHttp's WebSocket API is production-ready; use it instead of rolling your own or reaching for a bloated third-party library.
  • Implement exponential backoff starting at 1 second and capping at 30 seconds to handle transient failures and protect servers during outages.
  • Expose WebSocket events through Kotlin Flow via a sealed class (Connected, Disconnected, Message, Error) for clean reactive architecture integration.
  • Close WebSocket connections when the app backgrounding (ON_PAUSE) using Lifecycle observers to minimize battery drain and data usage.
  • Never block OkHttp's callback thread; always emit to Flow or launch coroutines on your scope to keep networking async.
  • Reset backoff on successful connection so transient failures retry quickly but outages back off gracefully.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles