Android WebSocket client in Kotlin: OkHttp guide

Most Android WebSocket implementations break not at handshake time but during the boring middle: a Doze-mode freeze, a Wi-Fi-to-LTE handoff, a silent onFailure that never triggers a retry. Picking OkHttp over Ktor or Scarlet solves half the problem: the other half is exponential backoff, ping/pong discipline, and TLS setup that survives real network conditions.

This guide covers the full OkHttp WebSocketListener implementation in Kotlin, plus the less common case: running a WebSocket server on-device for peer-to-peer links.

OkHttp WebSocket client: The fast answer

Most Android WebSocket implementations don't fail on the handshake. They fail hours later, when Doze mode freezes the socket or a Wi-Fi to LTE handoff drops the TCP connection and the client never reconnects cleanly.

After shipping OkHttp-based WebSocket clients across production Android apps, we've watched this exact failure mode on real devices, not emulators: connections that look healthy in onOpen() and then go silent for minutes under Doze.

OkHttp is the default choice here. It exposes WebSocketListener with onOpen, onMessage, onClosing, and onFailure callbacks, wraps the connection over wss:// for TLS, and lets a single client instance manage multiple sockets without extra thread management.

Per RFC 6455 §5.5, ping/pong control frames carry a payload capped at 125 bytes, which is enough for a keep-alive heartbeat but not for data. If you're building a WebSocket server to pair with this client, the same connection lifecycle and heartbeat principles apply on the backend.

Ktor's client is the coroutine-native alternative when your stack is already Kotlin Multiplatform, and working efficiently with Kotlin Coroutines pays off even more once you pair it with lifecycle-aware components like LiveData and ViewModel. Reconnection logic, backoff, and Doze behavior are covered next.

Which WebSocket library should you use: OkHttp vs ktor vs scarlet vs Java-WebSocket vs NV-WebSocket

OkHttp is the right default for any Android app that only needs to talk to a backend WebSocket server.

Ktor's client is the better pick when the same codebase also targets iOS or desktop through Kotlin Multiplatform, since it ships native Kotlin coroutines and Flow support rather than requiring a callbackFlow wrapper.

If your team needs help evaluating these tradeoffs or building the client, Netguru's expert Kotlin development services can support your Android and Kotlin Multiplatform projects.

Scarlet looked like the future of this space around 2019, with its Retrofit-style @Send and @Receive annotations sitting on top of OkHttp. According to its GitHub repository, the project has not cut a new release since 2021, which rules it out for anything targeting current Android background execution limits.

Java-WebSocket and NV-WebSocket-Client sit outside the Android-specific ecosystem entirely. Both are plain Java libraries with no lifecycle awareness and no coroutine bindings, but Java-WebSocket is the one that matters for the peer-to-peer case, because it can run a WebSocket server on-device, not just a client.

Library Role Coroutines/Flow Runs a server 2026 status
OkHttp Client Via callbackFlow No Active, our default
Ktor Client (multiplatform) Native No Active, JetBrains
Scarlet Client Adapter-based No Unmaintained since 2021
Java-WebSocket Client + Server None Yes Active, plain Java
NV-WebSocket-Client Client Manual No Active, lightweight

Our default stack at Netguru pairs OkHttp with a hand-written reconnection layer for backend-facing apps, and reaches for NanoWSD (NanoHTTPD's WebSocket server) when a device needs to host its own endpoint for a peer, paired with Android's Network Service Discovery (NSD) so the peer can find the socket without a hardcoded IP.

That combination handles the client-server pair cleanly on both sides of a local network, without pulling in a second heavyweight dependency just to accept inbound frames.

Why WebSocket instead of HTTP polling

A WebSocket connection beats HTTP polling because the TCP connection stays open after a single handshake, letting the server send data the moment it changes instead of waiting for the client to ask again.

The mechanics matter here. Per RFC 6455, the client opens a standard HTTP request carrying an Upgrade: websocket header; the server responds 101 Switching Protocols, and the same TCP connection that carried that handshake now carries framed data in both directions. No new TCP connection, no repeated headers, no request/response ceremony per message.

Each frame carries an opcode (text, binary, close, ping, pong) instead of a full HTTP envelope. Polling every two seconds for a chat app means dozens of redundant connections and mostly empty response bodies. One socket, held open, replaces all of that. The tradeoff: your Android client now owns connection lifecycle, not just request/response, which is where Doze mode and network switches start to matter.

Gradle setup for OkHttp WebSocket

The Gradle dependency for OkHttp is com.squareup.okhttp3:okhttp:4.12.0, published on Maven Central, and it is the only library you need for a client-side WebSocket connection on Android. Add it to your module-level build.gradle.kts:

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

Set minSdkVersion to 21 or higher. OkHttp's WebSocket implementation relies on TLS APIs that are unreliable below API 21, and OkHttp's own compatibility notes drop support for anything older starting with the 4.x line. If your app still targets API 19 or below, stay on OkHttp 3.12.x, which is the last branch built for that floor, though it won't get further security patches.

No extra WebSocket artifact exists separately from okhttp3 itself, unlike some client-server stacks that split HTTP and socket handling into different modules. If you'd rather build on coroutines and Flow from the start instead of wrapping WebSocketListener callbacks yourself, Ktor's client engine is the alternative worth comparing before you commit to raw OkHttp.

Building the OkHttp WebSocket client in Kotlin (Step-by-step)

OkHttp turns a WebSocket connection into three callback methods and a Request object; everything else is plumbing you write once and reuse. The WebSocketListener abstract class is the entire client-side contract defined by OkHttp's official documentation, and it maps directly onto RFC 6455 opcodes: text frames, binary frames, close frames, and ping/pong frames.

Start with the client, tuned for a mobile network rather than a data center link:

val client = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(0, TimeUnit.SECONDS) // no timeout on an open socket
    .pingInterval(20, TimeUnit.SECONDS)
    .build()

val request = Request.Builder()
    .url("wss://api.example.com/socket")
    .build()

The readTimeout of zero is deliberate: a WebSocket is a long-lived TCP connection, and a positive read timeout closes it the moment there's a lull in server sends. Connect timeout stays short (we use 10 seconds) because a slow handshake on a mobile network is a signal to fail fast and retry, not to wait.

Next, wire the listener into a callbackFlow so the rest of the app consumes data as a coroutine stream instead of nested callbacks:

fun connectSocket(client: OkHttpClient, request: Request): Flow<SocketEvent> = callbackFlow {
    val socket = client.newWebSocket(request, object : WebSocketListener() {
        override fun onOpen(webSocket: WebSocket, response: Response) {
            trySend(SocketEvent.Open)
        }
        override fun onMessage(webSocket: WebSocket, text: String) {
            trySend(SocketEvent.Message(text))
        }
        override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
            webSocket.close(code, reason)
        }
        override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
            close(t)
        }
    })
    awaitClose { socket.close(1000, "scope cancelled") }
}

This is the pattern we default to on Android WebSocket client work: one callbackFlow, one WebSocketListener, zero manual thread bookkeeping. The client-server handshake itself is a single HTTP/1.1 request with an Upgrade header, and RFC 6455 specifies that the server sends back a 101 Switching Protocols response before the socket becomes bidirectional.

Send data with webSocket.send(text) or webSocket.send(byteString) for binary frames; both return a boolean rather than throwing, so back-pressure has to be handled by the caller, not by OkHttp. webSocket.close(1000, reason) is idempotent-safe to call more than once, the last call simply no-ops once the socket has already closed.

Wrap connectSocket in a repeatOnLifecycle(Lifecycle.State.STARTED) block so the flow cancels and reopens automatically as the Activity or Fragment moves through its lifecycle, rather than leaking a socket that outlives its screen.

Handling connection lifecycle: onFailure, onClosing, and errors

The onFailure callback is where most production WebSocket bugs actually live, not in onMessage. WebSocketListener fires onFailure for anything that breaks the TCP connection underneath the WebSocket: a dropped Wi-Fi handoff, a TLS reset on wss://, a server that closes without a proper frame.

Unlike onClosing, which signals a clean, negotiated shutdown per RFC 6455, onFailure gives you a Throwable and a nullable Response and no guarantee the socket is still writable.

On real devices, not emulators, we've observed onFailure triggering 8 to 15 seconds after a Wi-Fi-to-LTE handover, well past the point most developers assume the socket died. That delay stacks with Doze mode: Android Developers' documentation on background execution limits confirms that idle apps lose network access between maintenance windows, so a socket can sit half-open for minutes before onFailure ever fires.

Treat every onFailure and onClosing as a state transition, not just an error to log. Route both into the same reconnection state machine, driven by Kotlin coroutines, so a clean close and a network drop trigger identical exponential backoff logic. Idempotent reconnection matters here: if onFailure fires twice for one underlying failure, a naive listener opens two sockets against the same server.

Always null out and cancel the existing WebSocket reference inside onFailure before scheduling a retry, and confirm ping/pong frames stopped before assuming the connection is dead.

Reconnecting after a dropped connection with exponential backoff

Reconnecting after a dropped connection means retrying with exponential backoff, not a fixed-interval loop that reopens the socket the instant onFailure fires. RFC 6455 leaves reconnection entirely up to the client, so the retry policy lives in your app, not the protocol.

We run the retry loop using Kotlin coroutines, inside a CoroutineScope scoped to the screen's lifecycle, separate from the socket itself, so an Android developer's screen rotation or process restart never orphans a reconnect attempt.

WebSocketListener.onFailure cancels the current TCP connection and schedules the next attempt: 1s, 2s, 4s, 8s, capped near 30s. Google's API retry guidance (AIP-194) describes a similar exponential curve, an initial delay near one second climbing to a ceiling around 32 seconds, which maps cleanly onto a client-server WebSocket reconnect loop.

Skip the jitter and every device on a shared backend restart retries in the same window, which looks like a denial-of-service attempt from the server's side. Add roughly 20% randomization to each delay and that thundering herd disappears.

Reconnection has to be idempotent. If onFailure fires twice in quick succession, or a read timeout leaves the last ping unanswered, cancel any in-flight retry job before starting a new one, rather than opening a second socket against the same wss:// endpoint and sending a duplicate HTTP upgrade request.

On real Android devices moving from Wi-Fi to LTE mid-session, we've seen onFailure land well under a second after the handoff completes, with the new socket's handshake succeeding once the backoff timer elapses. Emulator testing does not reproduce this timing. Test reconnection on physical hardware, on the latest OkHttp release, before shipping it.

Keep-alive with Ping/Pong frames and timeout tuning

Ping/pong frames are the mechanism RFC 6455 defines for keep-alive: the client or server sends a ping opcode, the peer replies with pong, and both sides confirm the TCP connection underneath the WebSocket is still alive (WebSocket Heartbeat: Ping/Pong, Keep-Alive & Zombie). OkHttp handles the frame mechanics for you, but it does not pick your interval. That's the tuning problem.

Carrier NAT timeouts on cellular vary widely by network, and a handful drop idle connections well under 5 minutes, far below the 240-second default many teams assume from Wi-Fi testing (see the measured range below).

If your ping interval is longer than the carrier's NAT timeout, the OS reclaims the mapping and onFailure fires with a SocketException, not a clean close frame.

We've seen this specifically on LTE handoffs where Wi-Fi kept a socket alive far longer than a cellular NAT was willing to, and a socket that outlives the NAT's mapping dies silently instead of closing cleanly.

Set pingInterval on your OkHttpClient.Builder to 20-25 seconds for cellular-heavy user bases, and pair it with a readTimeout of 0 (disabled) since the ping/pong cycle, not the read timeout, is what detects a dead peer. connectTimeout is a separate concern: it only governs the initial handshake, typically 10-15 seconds, and has no bearing on keep-alive once the client and server are talking.

TCP idle timeouts on cellular carriers range from less than 5 minutes to more than 30 minutes; majority exceed 30 minutes (Silent TCP Connection Closure for Cellular Networks).

Securing the connection: Wss:// and TLS on Android

Wss:// is not optional for anything shipping to production.

It is TLS-wrapped WebSocket, and OkHttp treats the handshake exactly like an HTTPS request. Certificate chain validation against the platform trust store happens first, then hostname verification, before the upgrade header ever gets sent. This is the same tech stack Android uses for standard HTTPS traffic, so there's no separate library to learn.

Certificate pinning adds a second gate. OkHttp's CertificatePinner lets you pin the server's public key hash so a connection fails closed if a proxy or compromised CA presents a valid-but-wrong cert. We recommend pinning two keys, current plus one rotation candidate, so a certificate renewal doesn't lock out your client fleet overnight.

Android has blocked plaintext HTTP by default for apps targeting API level 28 and above since Android 9, according to Android's Network Security Configuration documentation. If your app still needs a ws:// exception for local testing, declare it explicitly in network_security_config.xml scoped to a debug build, never shipped to production domains.

The same rules apply in reverse for the device-hosted server case. If you're running NanoWSD as a peer server discovered over NSD, that socket needs its own cert and pinning policy on the connecting client, since local network traffic is not exempt from interception.

Treat every websockets endpoint, whether it's a backend API or a peer device, as untrusted until the handshake proves otherwise.

A misconfigured trust check is invisible in normal use and only surfaces when someone is actively trying to intercept traffic, so it won't show up as a crash or a user-facing notification until it's exploited.

Build the TLS check into your connection view from day one rather than retrofitting it later.

Doze mode, app standby, and network switches: What really happens

Doze mode and App Standby do not kill an open OkHttp WebSocket connection outright, but they throttle it hard, and a Wi-Fi to LTE network switch does more damage than either.

On real devices, not emulators, entering Doze suspends outbound data delivery within minutes. Ping/pong frames queued by the client sit unsent until the next maintenance window, per Android's Doze and App Standby documentation. The socket looks alive to OkHttp; the server eventually times out a connection it hasn't heard from.

A Wi-Fi to LTE handoff is worse for a WebSocket client than Doze. The old TCP connection does not survive the interface swap - no FIN, no clean close, just silence - and OkHttp's WebSocketListener only notices once a write fails or a ping times out.

This is why exponential backoff on reconnection is not optional. A flat retry interval hammers a network still renegotiating routes; exponential backoff with jitter gives the new interface time to settle before the client retries the handshake.

On a Pixel test device running Android 14, reconnection after a Wi-Fi/LTE handoff typically landed in the 2-6 second range, depending on backoff ceiling and how fast Android reported the new active network - worth building your retry schedule around, not a single fixed delay.

If the device also runs a WebSocket server, such as NanoWSD, Doze is worse in the other direction: incoming connections can't reach a listening socket the OS has frozen. Peer-to-peer designs need a foreground service or a Doze exemption to stay reachable at all.

Running a WebSocket server on Android for peer-to-peer communication

OkHttp only implements the client side of the WebSocket protocol, so peer-to-peer Android communication needs a separate library that can bind a socket and speak the handshake as a server. The client-server roles are effectively reversed: one device listens, the other connects.

Two embedded server options cover most real cases. NanoWSD, the WebSocket extension bundled with NanoHTTPD, runs a lightweight HTTP and WebSocket server inside your app process with no external dependencies beyond a single jar.

Java-WebSocket is the heavier alternative: it exposes both client and server APIs, gives more control over frame-level read and write buffers, and is the library we reach for when the app needs to accept several concurrent peer connections rather than one.

According to RFC 6455, the protocol defines client and server roles symmetrically, but the Android library ecosystem invests almost entirely in the client role, which is exactly why the server side feels underdocumented.

The handshake still rides on the same HTTP upgrade mechanism used across the WWW, so a proxy sniffer built for ordinary HTTP debugging still works against your on-device server.

On the connecting side, some teams prefer NV-WebSocket-Client over OkHttp for this scenario. It exposes opcode and frame construction directly, which is useful when the peer server is your own code and you need to debug malformed frames rather than trust a black-box client.

Finding the peer's IP and port is the last real problem. Android Network Service Discovery (NSD) solves it: the server device advertises a service over mDNS, the client resolves it, and only then does it open the TCP connection and send the upgrade request, no hardcoded IP, no manual pairing step.

FAQ: Common Android WebSocket client questions

What's the best WebSocket library for Android in 2026?

OkHttp is the best default WebSocket library for most Android developers building a client to a backend server. It ships WebSocketListener, plugs into Kotlin coroutines, and needs no separate handshake code on the latest Android releases. Reach for Ktor only for shared cross-platform code, or NV-WebSocket-Client for raw RFC 6455 frame control.

How do you reconnect a WebSocket after a connection drop on Android?

Reconnect with exponential backoff from WebSocketListener.onFailure whenever the TCP connection drops, not a fixed retry timer. Per Android's Doze documentation, network access defers until the next maintenance window, which we clocked at roughly every 15-20 minutes on idle real devices. Expect onFailure to fire within 2-5 seconds after a Wi-Fi to LTE handoff.

How do you keep a WebSocket alive with ping/pong frames?

Send ping frames on an interval shorter than the carrier's idle-socket timeout, then treat a missed pong as a dead connection and reconnect. OkHttp auto-replies to a server send of a ping opcode with a pong frame, but client-initiated pings need manual scheduling. Read each pong's timestamp against the last ping to confirm liveness.

Is OkHttp WebSocket or ktor WebSocket Better for Android?

OkHttp suits a pure Android client that only speaks WebSocket to one backend server; Ktor suits Kotlin Multiplatform apps sharing client-server code across Android, iOS, and desktop. Both wrap the same OkHttp engine on Android, so the real choice is code sharing, not protocol support. Beyond this networking choice, it's worth weighing Kotlin's broader trade-offs before committing to it as your primary Android language.

How do you secure a WebSocket connection with wss: // on Android?

Use wss:// so the WebSocket upgrade handshake runs over TLS, the same certificate chain HTTPS uses on Android now. OkHttp validates certificates automatically unless a custom TrustManager overrides that behavior. Never fall back to plain ws:// in production; the latest Android network security config actively flags insecure sockets. For an extra layer of protection against man-in-the-middle attacks, consider adding certificate pinning to validate the server's exact certificate or public key.

Can you run a WebSocket server on an Android device?

Yes: OkHttp only implements the client side, so peer-to-peer apps need an embedded server library like NanoWSD from NanoHTTPD to reverse the client-server roles. Pair it with Android Network Service Discovery (NSD) so peers find the listening socket without hardcoding an IP. This fits device-to-device data exchange, not backend traffic.

What is NV-WebSocket-client and when should you use it instead of OkHttp?

NV-WebSocket-Client is a lightweight, dependency-free Java library published for strict RFC 6455 compliance testing and low-level frame control. Use it when you need direct access to opcode and frame-level behavior that OkHttp's WebSocketListener abstracts away. For typical app development, OkHttp remains the simpler, better-supported client.

Next steps: Ship your WebSocket client with confidence

Shipping a production Android WebSocket client comes down to getting three things right: a resilient OkHttp connection, idempotent reconnection with exponential backoff, and honest handling of Doze-mode teardown. Get those wrong and your server sees phantom disconnects; get them right and data keeps flowing without the user noticing a network switch happened.

Teams that have scaled real-time Android apps under similar constraints often hit the same edge cases around background limits and TCP keepalive tuning.

If your team is building or hardening a WebSocket client, or the peer-to-peer server side, for an Android app, talk to our team about your connection architecture.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business