Case study

ReadThat 9: Networking Deep Dive

5 min read✦ AI generated with human guidance & review

A shared transport contract with optimized native engines: Android reuses one process-wide HTTP/3 or HTTP/2 pool under API, images, and Media3; iOS keeps one URLSession plus native AVPlayer HLS, with stable cache identity and constrained-network policy.

Part 9 of the ReadThat case study.

📲 Try it live: Download the ReadThat APK (8.3 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: do not let every library silently create another client. The transport contract lives in shared :core:network. On Android, API JSON, Coil image loads, and Media3 segments all pass through one UnifiedTransport; on iOS, one process-long URLSession serves API, images, uploads, previews, and telemetry while AVPlayer retains the native HLS path.

/**
* 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#L109-L114

What Android’s shared engine buys, in latency terms: DNS, TLS tickets, congestion-control state, and multiplexed connections are amortized across request types. When origins overlap, a warmed connection can avoid another setup; even when API, image, and Stream origins differ, there is still one engine and one pool owner rather than three unrelated defaults. On HTTP/3, later requests to a known origin can use session resumption. Three separate clients (the common accident: OkHttp for API, Coil’s default, ExoPlayer’s default) means duplicate pools and inconsistent security/telemetry policy.

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#L203-L226

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)
.followRedirects(true)
// Same-scheme redirects stay enabled, but TLS can never be
// downgraded to cleartext by a server response.
.followSslRedirects(false)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS) // uploads
.build()

UnifiedTransport.kt#L234-L252

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. Platform adapters observe network changes and project them into the shared playback policy: Android registers a ConnectivityManager.NetworkCallback, while iOS resolves constrained/expensive access before constructing preload requests. The shared UI consumes the resulting policy without importing either platform API.

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 →