OkHttp 5.5 Upgrade: Real Value Lies in 5.2–5.4 Features, Not ECH
This article analyzes OkHttp 5.5's features, concluding that Encrypted Client Hello (ECH) requires Android 17 and offers limited immediate value, while versions 5.2–5.4 deliver practical upgrades like Zstd compression, 16KB page alignment, enhanced interceptor permissions, DNS-over-HTTPS, and QUERY method support, recommending a staged upgrade starting with 5.4.
ECH Is 5.5's Only Headline Feature — and It Has a Hard Requirement
Encrypted Client Hello (ECH) encrypts the SNI during the TLS handshake. In standard TLS, the ClientHello sends the server_name in plaintext, allowing middleboxes to see the requested domain. ECH moves the real SNI into an encrypted inner ClientHello, exposing only a public name and IP in the outer layer.
// Standard TLS: ClientHello(SNI=wikipedia.com)
// ECH: ClientHello(SNI=public.example) + encrypted {
// inner: wikipedia.com;
// }ECH requires fetching keys from DNS HTTPS records, which is why OkHttp rewrote the DNS API. In 5.5, you enable it via DnsOverHttps:
// Both clients must share the same connection pool/scheduler, otherwise DNS queries create their own connections
val bootstrapClient = OkHttpClient()
val client = bootstrapClient.newBuilder()
.dns(
DnsOverHttps.Builder()
.client(bootstrapClient)
.url("https://1.1.1.1/dns-query".toHttpUrl())
.build()
)
.build()
val response = client.newCall(
Request.Builder().url("https://example.com/").build()
).execute()The catch: ECH relies on the platform TLS stack, so it only works on Android 17 (API 37, released 2026-06). On the JVM, the same code falls back to plain TLS and cannot encrypt the SNI.
// Don't opt-in unconditionally in production; guard with a platform check
if (Build.VERSION.SDK_INT >= 37) {
client = client.newBuilder().dns(AndroidDns()).build();
} AndroidDns()is simpler, but the documentation notes its default DNS queries are unencrypted, cutting the ECH privacy benefit in half. For full effect, use DoH.
DNS Interface Rewrite — More Valuable Than ECH Itself
The Dns interface was changed from returning only List<InetAddress> to supporting various resource records, asynchronous streaming responses, and built-in in-memory caching. Previously, a custom resolver had to handle caching and concurrency manually.
A real-world pitfall appears in the DnsOverHttps implementation. It uses an OkHttpClient internally to make the DoH request. If you naively create a new OkHttpClient() instance and pass it in, you end up with two separate connection pools and thread pools. The DNS query then has to perform its own TCP+TLS handshake on cold start, adding latency. The bootstrap client pattern is mandatory, not ceremonial.
// Wrong: two clients, each with its own connection pool; DNS resolution also does a handshake
val bad = OkHttpClient.Builder()
.dns(DnsOverHttps.Builder().client(OkHttpClient()).url(url).build())
.build()5.4 Elevates Interceptor Permissions
Before 5.4, interceptors could only modify the Request. Swapping the connection pool, DNS, or socket factory required wrapping a Call.Factory, adding an extra layer to the call chain.
Starting with 5.4, an interceptor can override any setting on OkHttpClient.Builder: cache, connection pool, socket factory, DNS. This enables multi-tenant isolation, canary routing, and dedicated internal-service connections — all decided at the interceptor entry point, leaving logging, retry, and auth logic untouched.
5.4 also adds an HTTP/2 response header total limit of 256 KiB. When a stressed server floods the response with Set-Cookie headers, this limit protects memory.
5.2 and 5.3 — Features That See More Production Use
Zstd compression is packaged separately as okhttp-zstd. If the server supports it, you save bandwidth automatically.
// Negotiation order is up to you; falls back to gzip if the server doesn't support zstd
val client = OkHttpClient.Builder()
.addInterceptor(CompressionInterceptor(Zstd, Gzip))
.build()5.3.0 upgrades the okhttp-zstd dependency on ZSTD-KMP to 0.4.0, fixing the 16 KB ELF alignment issue. From Android 15 onward, 16 KB page size is a hard requirement, not optional.
Request.toCurl() produces output identical to Chrome's "Copy as cURL", letting you paste a failing request directly into a terminal to reproduce.
curl -X POST \
'https://api.example.com/v1/orders' \
-H 'Content-Type: application/json' \
--data-raw '{"sku":"A-1"}'QUERY method is supported in 5.2, but the cache key is not computed automatically; you must supply it manually via cacheUrlOverride.
val body = """{"query":"{ order(id: 1) { id } }"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("https://api.example.com/graphql")
.method("QUERY", body)
// Without this line, the query request cannot be cached
.cacheUrlOverride(
"https://api.example.com/graphql?sha=${body.sha256().hex()}".toHttpUrl()
)
.build()Instrumentation: 5.3 adds tags to Call, cleaner than stuffing business fields into headers.
class TraceInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// Tag can be computed and reused; EventListener reads it directly
chain.call().tag(TraceTag::class.java) { TraceTag(traceId = UUID.randomUUID().toString()) }
return chain.proceed(chain.request())
}
}5.2 also introduces EventListener.dispatcherQueueStart() and dispatcherQueueEnd(). During load testing, these queue-time events reveal thread-pool contention more clearly than callStart/callEnd.
Dependencies for Upgrading to 5.5
dependencies {
// BOM manages versions; don't write 5.5.0 repeatedly
implementation(platform("com.squareup.okhttp3:okhttp-bom:5.5.0"))
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:okhttp-zstd") // optional
// Test library moved to new coordinates; old mockwebserver is deprecated
testImplementation("com.squareup.okhttp3:mockwebserver3-junit5")
}The new MockWebServer API uses immutable values; in JUnit 5, manage its lifecycle with @StartStop.
class ApiTest {
@StartStop
val server = MockWebServer()
@Test
fun returnsJson() {
server.enqueue(MockResponse(code = 200, body = """{"ok":true}"""))
// server.url("/") works directly; no manual start/stop needed
}
}The package name remains okhttp3, so upper-layer libraries like Retrofit need no changes. Since 5.0, Android uses a separate AAR; Gradle Module Metadata selects it automatically unless you have pinned coordinates with force.
Author's Verdict
Judging by ECH alone, 5.5 is not worth scheduling today — API 37 device share is still small, and the JVM side gets no benefit at all.
However, the 5.2–5.5 line solves several concrete problems: Zstd compression, JPMS compatibility, 16 KB page alignment, expanded interceptor authority, and a fully extensible DNS layer. That is the real "no-brainer upgrade" rationale.
Recommended upgrade path: adopt 5.4 first for the interceptor capabilities, then move to 5.5 only when your product actually needs ECH. There is zero migration cost between them.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Architect's Tech Stack
Java backend, microservices, distributed systems, containerized programming, and more.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
