← All problems

Mobile Core Concepts

Network Protocols

Why the transport you pick shapes latency, reconnection, and battery - and the protocol an interviewer expects you to justify for any live or streaming surface.

Free~22 min

Picking the wire protocol is one of the early critical decisions in any mobile design. Caching, retry, reconnect, lifecycle binding, server architecture - all of it follows from "which protocol do we speak, and which version of it." This page covers the six options worth knowing (HTTP/1.1, HTTP/2, HTTP/3, WebSocket, SSE, gRPC) plus long-poll as the legacy fallback, how each is initiated and framed, the head-of-line behavior that distinguishes them, and the Android client libraries that talk each one. Certificate pinning and image-pipeline specifics live in their own Core Concept entries; this page treats them as cross-links.

The transport landscape

Five axes determine the right protocol: direction (client-to-server, server-to-client, both), persistence (one-shot, long-lived), framing (text, binary, structured), multiplexing (one stream per connection, many streams per connection), and reconnect semantics (manual, automatic with offset).

ProtocolDirectionPersistenceFramingMultiplexingReconnectUse for
HTTP/1.1Request/responseConnection-pooled, short-livedText headers + bodyOne stream per TCPManual retryPlain REST, CDN-backed static
HTTP/2Request/responsePersistent, one TCP/many streamsBinary framesYes - many streams per TCPManual retryHigh-fan-out REST, mobile-to-CDN
HTTP/3 / QUICRequest/responsePersistent over UDPBinary framesYes - per-stream HoL eliminated0-RTT resumeLossy networks, head-of-line-sensitive
WebSocketFull-duplexPersistent (single TCP)Text or binary framesApp-level onlyManual + backoffChat, presence, live reactions
SSEServer → clientPersistent (single GET)Text events (id/event/data)HTTP/2 multiplexed if availableAutomatic via Last-Event-IDLive feeds, notifications, comment streams
gRPCRPC + streamingPersistent over HTTP/2Protobuf binaryYes - HTTP/2 streamsManual + backoffStrict-schema RPC, internal services
Long-pollServer → clientMany short HTTP requestsText JSONOne per connectionImplicit (new request)Legacy fallback behind hostile proxies
Rendering diagram…

HTTP - versions and what changes

The three HTTP versions are the same protocol semantically (verbs, headers, status codes) but very different on the wire. The mobile-relevant differences live in framing and head-of-line behavior.

HTTP/1.1

Text-framed. One in-flight request per TCP connection at a time. Pipelining was meant to let a client send multiple requests on one connection without waiting, but it requires strict response ordering and most servers/proxies disable it - in practice, 1.1 = one request per connection per round-trip. Clients work around it by opening 6 connections per host (the de-facto browser limit) and round-robining.

Head-of-line at the connection level: a slow response blocks subsequent requests on the same connection until it returns.

HTTP/2

Binary-framed. Many concurrent streams over one TCP connection - up to ~100 typically. Streams are independent at the application layer; a slow stream doesn't block others.

But TCP itself is still ordered. A dropped packet on the underlying TCP connection blocks all streams using that connection until retransmission. This is the "HoL at the TCP layer" tradeoff: better than 1.1 (no app-layer ordering constraint) but not free under packet loss.

Server push exists but is mostly deprecated in practice (low cache hit rate, complex invalidation); don't design around it.

HTTP/3 / QUIC

Runs on UDP. Streams are independent all the way down - a packet drop affects only its own stream, not the others on the same connection. 0-RTT connection resume: the second request after a connection drop can include payload in the handshake itself.

The catch on Android: full HTTP/3 support requires Cronet (Google's HTTP stack) - OkHttp is HTTP/2 only as of writing. For consumer apps on flaky cellular networks, HTTP/3 is meaningfully better; for backend-only apps, the OkHttp investment usually wins on developer ergonomics.

WebSocket

A WebSocket is a single TCP connection that starts as an HTTP/1.1 request and upgrades to a binary frame protocol via the Upgrade: websocket header. Once upgraded, both ends can send messages at any time on the same socket - full-duplex.

val client = OkHttpClient()
val request = Request.Builder().url("wss://api.example.com/ws").build()
val listener = object : WebSocketListener() {
    override fun onOpen(ws: WebSocket, response: Response) {
        ws.send("""{"type":"subscribe","channel":"post:abc:comments"}""")
    }
    override fun onMessage(ws: WebSocket, text: String) { dispatch(text) }
    override fun onFailure(ws: WebSocket, t: Throwable, r: Response?) {
        if (!intentionallyClosed.get()) scheduleReconnect()
    }
}
val webSocket = client.newWebSocket(request, listener)

Real WebSocket designs almost always layer an application-level multiplexing protocol on top: { "type": "subscribe", "channel": "post:abc:comments" }. One physical WS connection carries many logical channels, dispatched client-side by the channel field. Channel names are usually hierarchical with colons (post:{id}:comments, user:{id}:notifications) and map directly to backend pub/sub topics.

Reconnect with full-jitter backoff

WebSocket has no automatic reconnect - that's the app's responsibility. The standard pattern: exponential backoff with full jitter, gated by an AtomicBoolean intentionallyClosed flag so an explicit webSocket.cancel() doesn't trigger reconnection.

private var reconnectDelay = 1_000L
private val intentionallyClosed = AtomicBoolean(false)
 
override fun onFailure(ws: WebSocket, t: Throwable, r: Response?) {
    if (intentionallyClosed.get()) return
    scope.launch {
        delay(reconnectDelay)
        // Full jitter: random in [0, cap] - prevents thundering herd after server restart
        reconnectDelay = Random.nextLong(0, (reconnectDelay * 2).coerceAtMost(32_000))
        connect()
    }
}

Full jitter (not capped exponential) is the right shape because clients tend to disconnect in batches when a server restarts; without jitter, every client reconnects at the same 2 * delay instant and the server falls over again.

A WebSocket opened in a Composable's body leaks after navigation - wrap it in DisposableEffect with onDispose { disconnect() }, not LaunchedEffect, which has no cleanup hook to tear the connection down when the composable leaves the tree.

SSE - Server-Sent Events

One HTTP GET that the server never closes. Frames are newline-delimited text events with optional id, event, and data fields. The browser/client EventSource API auto-reconnects on disconnection and sends the last received id in the Last-Event-ID header so the server can replay missed events.

GET /api/feed/live HTTP/1.1
Accept: text/event-stream
Last-Event-ID: evt_882
 
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
 
id: evt_883
event: new_comment
data: {"id":"c99","body":"nice post","author":"alice"}
 
: keep-alive

SSE is plain HTTP - proxies, load balancers, and CDNs handle it like any other request. WebSocket's Upgrade header is often stripped by hostile proxies. If the design needs server push only (no client → server traffic on the same stream), SSE is the simpler, more proxy-friendly choice.

The : keep-alive comment frame in the example above is essential: proxies and load balancers idle out long-held connections (often at 30-60 s), so the server has to send periodic comment frames or the client sees mystery disconnections it didn't cause.

Android has no native EventSource. The pragmatic implementations: OkHttp with @Streaming (manual frame parsing), or LaunchDarkly's okhttp-eventsource library (handles reconnect + Last-Event-ID for you).

Long-poll

Client sends a request. Server holds the response open until something happens (new data, timeout). When the response returns, the client immediately fires the next request. Server push, simulated with vanilla HTTP/1.1.

You will meet it in two places: legacy backends that pre-date WebSocket adoption, and corporate proxies that strip the Upgrade header. The reasonable fallback ladder for a modern mobile design is: WebSocket → SSE → long-poll, switching down only when the upper option fails to connect (onFailure after handshake).

Don't design a system around long-poll today. Do build the WebSocket / SSE client to gracefully fall back to it.

gRPC streaming

gRPC = Protobuf over HTTP/2. The Android use case is internal services or back-office mobile apps where you control both ends; consumer-facing public APIs almost always stay on REST + JSON because of CDN compatibility and developer familiarity.

Four call shapes:

  • Unary - one request, one response. Like REST.
  • Server streaming - one request, stream of responses. Like SSE, but binary and on HTTP/2.
  • Client streaming - stream of requests, one response. Rare on mobile.
  • Bidirectional streaming - full-duplex stream. Like WebSocket, with strict schema.

The win over REST+JSON is payload size and parse cost (Protobuf binary is typically 30-50% smaller and 5-10× faster to parse than JSON), plus compile-time type safety. The cost is debuggability (binary on the wire, no curl inspection), CDN incompatibility (Protobuf payloads can't be edge-cached as text), and SDK weight (the gRPC Android client is ~1 MB).

Android client libraries

LibraryLayerHTTP versionBest for
OkHttpHTTP client + connection poolHTTP/1.1, HTTP/2The substrate. Use directly when you need raw interceptor / cache control.
RetrofitType-safe RPC over OkHttpWhatever OkHttp speaksThe default for declarative REST. Annotation-driven; delegates to OkHttp.
Ktor ClientKotlin-native, multiplatformHTTP/1.1, HTTP/2KMP projects; cleaner DSL than Retrofit; smaller in shared modules.
CronetGoogle's native HTTP stackHTTP/1.1, HTTP/2, HTTP/3Apps that need HTTP/3 on Android. Heavier binary; tighter Google ecosystem fit.

OkHttp's Interceptor chain is the central extension point - each interceptor sees the request on the way out and the response on the way back. Application-level interceptors run on every request (auth headers, logging); network interceptors run on the actual wire bytes (compression, retries). Token refresh is a separate beast - use Authenticator (called only on 401), with a two-client pattern so the refresh call itself doesn't carry the failing token and re-trigger the authenticator (infinite loop).

For coalesced token refresh under concurrent 401s, the Mutex.withLock pattern in the Concurrency entry is the canonical implementation.

See also

  • Certificate pinning - covered in the Security and Crypto Core Concept. The OkHttp CertificatePinner and network_security_config.xml paths both live there; this entry assumes a TLS connection is already trusted.
  • Image loading pipelines - the network half (HTTP cache headers, signed-URL TTL, range requests for resume) is here in spirit; the decode + memory side lives in the Image Loading Internals Core Concept.
  • Caching - HTTP-level cache semantics (Cache-Control, ETag, stale-while-revalidate) live in the Caching Strategies Core Concept.

Used in

  • Messenger App - WebSocket transport for chat, presence, and typing indicators; the channel-multiplexing protocol on top of one WS connection is the spine of the design.
  • Newsfeed App - SSE for the live-comment stream layered on top of HTTP/2 for the paginated feed reads; the Last-Event-ID replay is what makes "resume on backgrounded app" work without bespoke sync logic.
  • Photo Gallery App - HTTP PATCH with Upload-Offset for resumable chunked upload; the protocol choice (HTTP, not WebSocket) is itself a deep-dive in that page.
  • Networking Client SDK - the dedicated system-design problem on this topic. Designs the OkHttp-shaped library from scratch (interceptors, connection pool, dispatcher).
  • Ride-Sharing App - WebSocket for driver location push; the reconnect-with-jitter pattern matters because driver fleets reconnect in coordinated waves.

Done reading? Mark it so it sticks in your dashboard.