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.

Discussion (0)

Sign in to join the discussion.
No comments yet. Start the discussion.