ReadThat 9: Networking Deep Dive
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 {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()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()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
SimpleCachewith 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:
| Layer | Metered behavior |
|---|---|
| QUIC migration | never uses a non-default (possibly metered) network |
| Periodic feed refresh | WorkManager network-constrained; video pre-cache only on unmetered + validated + autoplay-enabled |
| Preload manager | allowPrefetch policy flag flows into the preload window; data-saver collapses tiers |
| ABR | data-saver policy caps track selection (:core:media video policy) |
| Uploads | staged 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 →