Sarie lets OkHttp 5.x Android apps speak HTTP/3 (QUIC) through Chromium's Cronet networking stack, without touching your OkHttpClient or interceptors.
Sarie is a transport bridge for Android apps using OkHttp (and Retrofit). It brings Cronet's HTTP/3 (QUIC), connection migration across Wi-Fi and cellular, and Chromium's connection management to your existing OkHttp client.
- No client changes: no interceptors to add or reorder, no custom
Call.Factory. - Pre-send safety: requests Cronet cannot serve (WebSockets, proxies, custom trust, pins Sarie did not install) fall back to stock OkHttp before any network I/O.
Why bother: Reddit moved Android feed traffic to HTTP/3 and saw feed failure rate −10.1%, feed latency −1.36%, and slow video starts −14.53%. Read Part 1 and Part 2 of their write-up.
- Build-time bytecode rewriting: the Gradle plugin rewrites five internal OkHttp call sites at packaging time:
ConnectInterceptor(the bridge trampoline),CallServerInterceptor(a prefix), two cacheisHttpschecks, andRealCall.cancel()(an appended cancel hook). - Pre-send routing: allowed HTTPS requests go to Cronet, with the OkHttp cache, network interceptors, and authenticator still in the path. Everything else (cleartext, WebSockets, custom trust, proxies, opt-outs) stays on stock OkHttp.
- Transparent to application interceptors: the swap happens at
ConnectInterceptor, so logging, tracing, auth, and header interceptors run normally above it.
- Android application module (the rewrite happens when packaging the APK)
- OkHttp 5.4.0 or 5.5.0 (older versions, including OkHttp 4, fail the build; newer versions warn)
- minSdk 24+
- A Cronet provider on the classpath (embedded/bundled, platform HttpEngine, or Play Services)
Apply the plugin to your application module, even if OkHttp is declared in a library module:
// app/build.gradle.kts
plugins {
id("com.android.application")
id("com.carlonzo.sarie")
}
dependencies {
implementation("com.squareup.okhttp3:okhttp:<okhttp_version>")
implementation("com.carlonzo.sarie:bridge:<sarie_version>")
}Optional plugin configuration:
sarie {
enabled.set(true) // false: no bytecode rewrite and no version guards (default true)
failOnUntested.set(false) // true: an untested (newer) OkHttp fails the build instead of warning
allowUnfingerprinted.set(false) // true: a fingerprint mismatch warns instead of failing
instrumentKtor.set(true) // false: skip the Ktor < 3.3 HTTP/3 compat patch (COMPATIBILITY.md row 37)
}The bridge has no runtime dependency on Cronet (compileOnly against cronet-api). Add one provider; Sarie picks the first available in this order:
| Preference | Provider | Artifact | Min API | Notes |
|---|---|---|---|---|
| 1 | App-packaged (embedded / bundled) | org.chromium.net:cronet-embedded:500.0.2 |
24+ | Ships in the APK; no downloads or IPC. |
| 2 | Play Services | com.google.android.gms:play-services-cronet:18.1.1 |
24+ (with GMS) | Smallest APK, frequent updates. |
| 3 | Platform HttpEngine | System (org.chromium.net:cronet) |
34+ | Used when Play Services is absent or fails. |
Every 500.x Cronet artifact pulls in org.chromium.net:cronet, so API 34+ devices get HttpEngine with any Cronet dependency. The Java fallback provider (HttpURLConnection) is never used.
With Play Services, Sarie runs CronetProviderInstaller in the background during install; you don't call installProvider yourself. Until it finishes, calls use stock OkHttp (engine_missing).
dependencies {
// Option A: embedded (consistent across devices)
implementation("org.chromium.net:cronet-embedded:500.0.2")
// Option B: Play Services (smaller APK)
// implementation("com.google.android.gms:play-services-cronet:18.1.1")
}Call install off the main thread, as early as you can:
// In Application.onCreate() or a startup background task
SarieBridge.install(context) {
certificatePinner(client.certificatePinner) // optional: pins to install
if (BuildConfig.DEBUG) {
debugLogger(SarieLogger.Logcat) // logs to logcat under tag "Sarie"
}
}Sarie builds the engine (HTTP/3 and HTTP/2 on, brotli on, stale DNS on) and never shuts it down. Cronet runs in HTTP_CACHE_DISK_NO_HTTP mode: QUIC/Alt-Svc state and the DNS host cache persist on disk, but response caching is left to OkHttp's Cache. Calls made before install returns use stock OkHttp and do not wait.
That's it. Existing OkHttpClient and Retrofit code is unchanged:
client.newCall(request).execute().use { response ->
println(response.protocol) // HTTP_3 when negotiated
}Google's cronet-transport-for-okhttp inspired Sarie. The main difference is where the bridge sits: Google's is an application interceptor (it must be last, and every client must be configured), while Sarie rewrites ConnectInterceptor so every OkHttp call in the app and its dependencies goes through it.
| Feature / Behavior | google/cronet-transport-for-okhttp | Sarie |
|---|---|---|
| Integration | Add CronetInterceptor or use CronetCallFactory per client |
Build-time rewrite; no client changes |
| Interceptor placement | Must be last; interceptors after it are silently skipped | Below all application interceptors |
| Network interceptors (Flipper, Chucker) | Never run | Run before the Cronet hop |
| Unsupported client config (proxy, unbridged pins, custom trust, …) | Sent to Cronet anyway; OkHttp config silently bypassed | Falls back to stock OkHttp pre-send; SarieListener.onRouted gets the reason |
| OkHttp cache | Cronet responses never cached | Cached by OkHttp (a hit has handshake == null); Cronet caches no responses |
| WebSocket | Fails | Routed to stock OkHttp |
| Cancellation | ~500 ms poll loop | Immediate, via the rewritten RealCall.cancel() |
| Request tags | Dropped; CronetCallFactory throws |
Preserved |
| Transport failure | Terminal | Idempotent calls retried once before headers |
| HTTP 407 (proxy auth) | Can crash follow-up logic | Clean IOException; proxy clients denied pre-send |
| Request opt-out | Separate OkHttpClient / Call.Factory |
Per-request CronetOptOut tag |
| Wire metrics (bytes, DNS, connect, TTFB) | None | SarieListener.onFinished delivers SarieTimings |
| OkHttp version drift | Breaks at runtime | Plugin fails the build on an unexpected OkHttp shape |
| Cronet dependency | Bundled by host or library | None (compileOnly); host picks the provider |
Sarie does not fake an InetAddress, Connection, or handshake for OkHttp's EventListener, and network interceptors see decoded bodies because Cronet decompresses first.
SarieBridge.install(engine) { ... } uses a host-built CronetEngine and never shuts it down. It installs no pins, so pinned hosts fall back to stock. Compression and cache mode are whatever the host built, but Sarie calls disableCache() on every request, so Cronet never serves or stores Sarie responses.
DefaultPolicy() allows every HTTPS host that passes the safety checks. To restrict:
SarieBridge.install(context) {
policy(
DefaultPolicy {
allowedOrigins(setOf("api.example.com", "cdn.example.com:443")) // bare host means port 443
},
)
}Warning
Cronet uses Chromium's resolver and cannot take a custom one. client.dns is never called for requests routed to Cronet (same as Google's library). If you rely on it (host overrides, DoH, blocking), keep those requests off Cronet with CronetOptOut or the allowlist.
Tune Cronet's resolver in configure with setDnsOptions. It replaces Sarie's stale-DNS defaults, so set everything you want in one call.
Cronet normally learns HTTP/3 from an Alt-Svc header on a first TCP connection. Add a QUIC hint so the first connection tries HTTP/3, and send one HEAD after install to warm it up:
SarieBridge.install(context) {
configure { builder ->
builder.addQuicHint("api.example.com", 443, 443)
}
}
client.newCall(Request.Builder().url("https://api.example.com/").head().build()).execute().close()configure runs before Sarie sets brotli, the cache mode, the storage path, and pin bypass. It must not call addPublicKeyPins: Sarie appends the OkHttp client's pins afterwards and cannot remove pins already added.
Use the mapper, which sees every OkHttp request:
SarieBridge.install(context) {
mapper { request, builder ->
builder.setPriority(priorityFor(request))
}
}Per request:
val request = Request.Builder()
.url("https://api.example.com/special")
.tag(CronetOptOut::class.java, CronetOptOut)
.build()Process-wide kill switch, no rebuild needed:
System.setProperty("okhttp.cronet.enabled", "false")<cacheDir>/cronet-cache (cronet-cache-<suffix> in secondary processes) holds QUIC and Alt-Svc state. Deleting it is safe and only drops remembered HTTP/3 state; Android's "Clear cache" removes it too.
Enable debugLogger(SarieLogger.Logcat) in debug builds and filter Logcat by tag Sarie.
- Install (
INFO), once:Cronet installed (built|built, reused|borrowed): ...: engine ready.No enabled Cronet provider ...: no provider found; everything stays on OkHttp.Play Services CronetProviderInstaller failed ...: fell back to HttpEngine or stock OkHttp.
- Routing (
DEBUG), per call:GET https://host/path -> cronetor-> okhttp (reason=...). See Getting calls onto Cronet. - Protocol (
DEBUG), per Cronet call:-> h3or-> h2.
Routed to Cronet but -> h2?
- Alt-Svc not learned yet: the first connection is TCP until the server advertises HTTP/3. Use a QUIC hint.
- QUIC marked broken: after a failed handshake (e.g. UDP blocked), Chromium backs off to TCP for that host. Delete
<cacheDir>/cronet-cacheto reset. - Local / self-signed roots: Chromium only allows QUIC with known roots (
ERR_QUIC_CERT_ROOT_NOT_KNOWN), so Charles, mitmproxy, or private CAs stay on HTTP/2.
Every request is checked before any network I/O. If Cronet can't serve it safely, it goes to stock OkHttp with a FallbackReason, logged as -> okhttp (reason=<value>).
Fixable:
FallbackReason |
Trigger | Fix | Contract |
|---|---|---|---|
pins |
Host has a pin, but the engine is borrowed, pins differ from the installed set, or a single-label *.host wildcard is used |
certificatePinner(client.certificatePinner) on the built install; use exact hosts or **.host |
row 13 |
allowlist |
Host not in allowedOrigins |
Add it, or use an empty set / "*" |
row 18 |
content_type |
Body with nonzero or unknown length and no Content-Type |
Set a Content-Type on the body or headers |
row 35 |
engine_missing |
install not returned, no provider, or Play Services still initializing |
Add a provider; call install early off the main thread |
rows 20, 31, 33 |
content_encoding |
The request sets its own Accept-Encoding (any value, even gzip), or a network interceptor sets one that doesn't list gzip |
Drop the header and let Cronet negotiate and decode | row 24 |
cleartext (loopback) |
HTTPS to 127.0.0.1, localhost, ::1 |
DefaultPolicy { allowLoopbackHttps(true) } |
row 18 |
Intentional: disabled (kill switch or policy.enabled() == false, row 19), tag_opt_out (CronetOptOut tag, row 21).
Stays on OkHttp by design:
FallbackReason |
Trigger | Contract |
|---|---|---|
cleartext |
http:// scheme |
row 18 |
websocket |
OkHttpClient.newWebSocket |
row 17 |
h2_prior_knowledge |
client.protocols contains H2_PRIOR_KNOWLEDGE |
row 22 |
proxy |
Explicit Proxy or non-DIRECT ProxySelector |
row 12 |
socket_factory |
Custom SocketFactory |
row 14 |
hostname_verifier |
Custom HostnameVerifier |
row 14 |
trust |
Custom X509TrustManager or CA anchors |
row 16 |
policy_error |
Custom CronetPolicy.enabled() threw (fails closed) |
row 34 |
Application interceptors always run. Network interceptors also run on the Cronet path.
Pass a SarieListener to install:
onRouted: caller thread, before network I/O.reasonisnullfor Cronet, otherwise theFallbackReason. Tags are oncall.request().onResponseStarted: when Cronet response headers arrive, before theResponsegoes up the chain (so before interceptors andEventListener.callEnd). CarriesSarieResponseInfo: protocol, status, cache status, timestamps, attempt, redirect flag.onFinished: once per Cronet attempt (a retry reports twice). CarriesSarieTimings:dnsMs,connectMs,tlsMs,sendMs,ttfbMs,totalMs, raw timestamps, socket reuse, wiresentBytes/receivedBytes(compressed), error codes, andResult. It runs before the body read hits EOF / throws orclose()returns, unless the provider rejects finished listeners; then it runs later withdeliveredLate = true.
SarieBridge.install(context) {
listener(object : SarieListener {
override fun onRouted(call: Call, reason: FallbackReason?) {
val operation = call.request().tag(Operation::class.java)
}
override fun onResponseStarted(call: Call, info: SarieResponseInfo) {
val protocol = info.protocol // HTTP_3, HTTP_2, etc.
}
override fun onFinished(call: Call, timings: SarieTimings) {
val wireBytes = timings.receivedBytes
val ttfb = timings.ttfbMs
}
})
}Note
QUIC can raise p99 latency while reducing timeouts, because slow successes replace outright failures. Compare success rate and timeouts alongside latency percentiles.
response.protocolisHTTP_3orHTTP_2, as negotiated.EventListener.callEndfires when headers arrive; the body streams afterwards.callTimeoutbounds the header phase; body reads are bounded byreadTimeout.- The Sarie-built engine advertises
Accept-Encoding: gzip, deflate, brand decodes transparently. handshakeis always null, including cached HTTPS hits. SeeCOMPATIBILITY.md.
The :demo module compares OkHttp (stock) and OkHttp + Sarie side by side, with live metrics, protocol indicators, a log inspector, and Chucker.
./gradlew :demo:installDebugScenarios:
- Round trip: 20 sequential GETs to
cloudflare-quic.com; cold setup and warm p50/p95. - Parallel images: 100 concurrent image loads from
images.unsplash.com; wall time, first image, p50/p95/p99. - Connection setup: one cold GET to each of 4 origins; DNS, connect, TLS, TTFB.
- Big download / migration: ~9 MB on both stacks at once. Switch Wi-Fi ↔ mobile mid-download: QUIC migration keeps Sarie going, TCP breaks.
Simulate a bad network:
./scripts/netem.sh on 2% 100ms # 2% loss, 100ms delay
./scripts/netem.sh offCOMPATIBILITY.md: full behavior contract with test citations.