Case study

ReadThat 9: Networking Deep Dive

5 min read

One process-wide transport under API, images, and video: HTTP/3 with QUIC connection migration across Wi-Fi/cellular handoffs, an HTTP/2 fallback pool, HLS through the CDN, cache identity vs signed URLs, and respecting metered connections.

Part 9 of the ReadThat case study.

📲 Try it live: Download the ReadThat APK (8.8 MB, Android 8+). Open the file on your phone and allow “install unknown apps” if prompted.

One transport for everything

The single most consequential networking decision is organizational, not protocol-level: the process has one transport, and API JSON, Coil image loads, and Media3 segments all go through it.

/**
* Process-wide transport. A single HttpEngine owns QUIC sessions, TLS tickets,
* DNS state, and multiplexed connections for API, images, and video on API 34+.
* Android 8-13 share a single modern-TLS OkHttp HTTP/2 pool.
*/
object UnifiedTransport {

UnifiedTransport.kt#L107-L112

What sharing one engine actually buys, in latency terms: one DNS resolution, one TLS handshake, one congestion-control state, one connection-per-origin: amortized across every request type. When the feed has already talked to the API origin, the first video segment rides an established QUIC session instead of paying a fresh 1-RTT (or worse) setup. On HTTP/3, later requests to a known origin can use 0-RTT resumption. Three separate clients (the common accident: OkHttp for API, Coil’s default, ExoPlayer’s default) means three of everything and none of the reuse.

HTTP/3, and the handoff superpower

On API 34+ the engine is Android’s HttpEngine (Cronet-lineage) with QUIC enabled and, the headline feature, connection migration:

HttpEngine.Builder(context)
.setEnableHttp2(true)
.setEnableQuic(true)
.setEnableBrotli(true)
// Coil and Media3 own bounded disk caches. Disabling the transport
// cache avoids duplicating large image/video objects on disk.
.setEnableHttpCache(HttpEngine.Builder.HTTP_CACHE_DISABLED, 0)
.setConnectionMigrationOptions(
ConnectionMigrationOptions.Builder()
.setDefaultNetworkMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED)
.setPathDegradationMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED)
// Never keep racing an old, potentially metered network.
.setAllowNonDefaultNetworkUsage(ConnectionMigrationOptions.MIGRATION_OPTION_DISABLED)
.build(),
)
.apply { quicOrigins.forEach { host -> addQuicHint(host, 443, 443) } }
.build()

UnifiedTransport.kt#L199-L222

TCP connections are identified by the 4-tuple: walk out your front door, Wi-Fi drops, and every TCP connection dies mid-request. QUIC identifies connections by connection ID, so when the device moves from Wi-Fi to cellular, the same connection continues on the new path: in-flight requests survive, TLS state survives, a playing video keeps its congestion state. setPathDegradationMigration goes further: migrate before the old path fully fails, when it merely degrades.

The third option is the deliberate one: setAllowNonDefaultNetworkUsage(DISABLED). Racing the old network after the OS picked a new default can silently burn metered data. Continuity, yes; spending the user’s cellular plan to shave a retry, no.

Details worth stealing: QUIC hints are limited to known HTTPS origins (h3 negotiation without a first-visit Alt-Svc round trip), and the transport-level HTTP cache is disabled: Coil and Media3 own bounded, purpose-built disk caches, and a third opaque cache would duplicate large objects on disk (part 5).

The fallback pool, tuned rather than defaulted

Android 8–13 get one shared OkHttp client: every knob explicit:

OkHttpClient.Builder()
.connectionSpecs(listOf(ConnectionSpec.RESTRICTED_TLS)) // TLS 1.2+, modern ciphers
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))
.connectionPool(ConnectionPool(8, 5, TimeUnit.MINUTES))
.dispatcher(Dispatcher().apply {
maxRequests = 64
maxRequestsPerHost = 8
})
.retryOnConnectionFailure(true)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS) // uploads
.build()

UnifiedTransport.kt#L232-L250

Retry discipline is a correctness rule, not a config: the stack never retries a non-idempotent mutation without a stable idempotency key, which every ReadThat mutation carries (part 5), so a lost ACK during a network handoff can’t double-post.

HLS through the CDN

Video delivery is Cloudflare Stream ABR HLS (part 6); the client-side rules that make it fast and correct:

  • Manifests are never disk-cached: they’re dynamic; segments always are, in the shared SimpleCache with media-stable keys. cachePolicy: "segments_only" rides the wire model itself (Wire.kt#L107).
  • Feed payloads carry the full URL ladder (HLS/DASH/poster/preview/fallback), so playback needs zero discovery round trips.
  • Prefetch is tiered by distance: manifest-only at distance ≤4, tracks at 2, samples at ±1 (part 3): so the CDN sees cheap manifest fetches for speculation and segment fetches only where playback is probable.
  • Cache identity ≠ URL. Signed delivery URLs expire and rotate; cache keys are stable content identities for both Coil and Media3. Signature rotation costs zero cache hits.

The on-device transport probe asserts the whole stack end to end: API and Images negotiated h3 on the Pixel 10 Pro; the Stream origin negotiated its expected h2 fallback: same engine, per-origin protocol reality (UnifiedTransportInstrumentedTest.kt).

Respecting metered connections

Policy, layered, all observing the same connectivity state:

LayerMetered behavior
QUIC migrationnever uses a non-default (possibly metered) network
Periodic feed refreshWorkManager network-constrained; video pre-cache only on unmetered + validated + autoplay-enabled
Preload managerallowPrefetch policy flag flows into the preload window; data-saver collapses tiers
ABRdata-saver policy caps track selection (:core:media video policy)
Uploadsstaged durably, drained by constrained workers, never “now or lost”

The principle: speculation is free only on unmetered networks. User-initiated bytes flow anywhere; speculative bytes (prefetch, pre-cache, preload) check the network class first. Connectivity itself is observed as a Flow (the callbackFlow bridge pattern, part 10) so every layer reacts to changes rather than sampling.

Observability of the transport

Every request emits a network_request event: route template (never URL), negotiated protocol, status class, payload bytes, and the Cloudflare Server-Timing: edge duration: so client-total vs edge-time separates transport problems from backend problems, and per-origin h3 adoption is a tracked, alertable series (part 7).

📷 Diagram placeholder: one QUIC connection surviving a Wi-Fi→cellular handoff mid-video.


← Part 8: International strategy · Next: Part 10: Kotlin Flows in practice →