The forgotten cost of connection setup
TLS, DNS, TCP. The hidden 600ms before any of your code runs. Where to claw it back.
Loading essay…
TLS, DNS, TCP. The hidden 600ms before any of your code runs. Where to claw it back.
Loading essay…
A request to your server takes 600 ms. Your server processes it in 50. The other 550 are split between TLS, DNS, and TCP, all happening before your code runs. Most performance writeups treat this as a fixed cost. It is not. Here is where the time actually lives, and where to claw some of it back.
A cold HTTPS request from a phone on cellular to a server in another region:
DNS resolution 70 ms (cold cache)
TCP three-way handshake 80 ms (one round trip)
TLS 1.3 handshake 180 ms (one round trip)
HTTP request 5 ms
Server processing varies
HTTP response 5 ms
────────
≥350 ms before any data is exchanged
That number is for a healthy connection in average conditions. Cellular and rural networks add 100-300 ms of round-trip on top. We have measured 1.4 seconds of pre-data overhead on real-world Egypt- to-Frankfurt connections during peak hours.
The point is that the user-visible "this app is slow" is often nothing the application code did wrong. It is the cost of saying hello.
Three of the four steps are reducible.
A DNS lookup is one or two round-trips to a recursive resolver. Mitigations:
We use the first two. We do not use the third because we want to be able to change DNS without an app update.
TCP three-way handshake is one round trip and is unavoidable on a cold connection. Mitigations:
We use HTTP/2 with a connection pool. HTTP/3 is on our list but the proxy in front of our API doesn't support it cleanly yet.
TLS 1.3 is one round-trip. TLS 1.2 was two. Make sure you are on 1.3 end-to-end. The other moves:
Session resumption is on by default in modern stacks. 0-RTT is opt-in on most reverse proxies; check yours.
In rough order of how much we got back per hour of work:
Default Dart HttpClient and Java's HttpURLConnection keep
connections open. Our naive HTTP wrapper was creating a new
client per request. Reusing one client across the app saved a full
TCP+TLS round-trip on every request after the first. Average
request latency dropped 200 ms.
// before
Future<Response> get(String url) async {
final client = HttpClient();
// ... use client
client.close();
}
// after
class Api {
static final _client = HttpClient(); // shared, app lifetime
Future<Response> get(String url) async {
// ... use _client
}
}When the app starts, we issue a HEAD request to our API root.
Doesn't transfer any data, but warms DNS, opens the TCP connection,
and completes the TLS handshake. By the time the user does anything
real, the connection is ready.
Future<void> warmConnection() async {
try {
await _client.head(Uri.parse('https://api.aliyosef.online/health'));
} catch (_) {
// Best effort. Network might be down. Don't block boot.
}
}This is invisible to the user but makes the first real request feel instant. Cost: one extra request per session, ~5 KB. Worth it.
If your users are in one geographic region and your server is in another, no amount of protocol tweaking will save you from the speed of light. We moved our API from EU-West to ME-Central for our Saudi and Syrian users. Round-trip latency went from 220 ms to 30 ms. That single move saved more time than every other optimization combined.
Gzip on responses is table stakes. Brotli is better but not
universally supported. Both reduce wire bytes, which reduces the
multi-RTT cost on slow links. Set Accept-Encoding: gzip, br on
the client and configure the server to compress responses above
~1 KB.
The smartest fix is to not make the round-trip in the first place, which is the entire point of offline-first design. The first request after boot can be answered from local cache; the network is only used to refresh the cache, in the background.
Three optimisations we considered and rejected.
Saves one round-trip on TCP handshake but is poorly supported, especially in middleboxes (corporate proxies, cellular carrier gateways). Net negative for our user base; you may have a different profile.
Spreading the API across many hostnames means many DNS lookups, many TLS handshakes, many TCP connections. We consolidated to one hostname behind a reverse proxy and route by path. Saves on every cold connection.
Server push had promise on paper but rarely makes things faster in practice. The browser/client preload signals are easier to use and better tuned. We don't use server push.
Two specific numbers:
Every API call records DNS, TCP-connect, TLS, request-write, server-wait, response-read. The breakdown matters: if DNS is the biggest chunk, the fix is different from "TLS is the biggest chunk." Aggregate TTFB hides which part to optimise.
Of all our requests, what percentage are made over a connection that was already open? Should be > 95 % steady-state. If it drops, something is killing connections (proxy timeouts, server limits, client misuse).
Most "the app is slow" complaints are about connection setup, not processing. The fixes are mostly structural: keep one connection open, pre-warm it at boot, put your server geographically close to your users, compress everything. None of it is glamorous; all of it moves the numbers.
Browse the performance topic for the rest of the series, or jump into the sync engine series for a different take on what bad networks do to architecture.