The life of an HTTPS request
What actually happens between calling fetch() and getting a response?
Before your request leaves the machine, the client resolves a name, opens a transport, negotiates TLS and an HTTP version, and only then sends bytes, usually to a CDN edge rather than your origin. A warm, reused connection skips all of that, which is why connection reuse and timeouts matter more than most code-level tuning.
THE MENTAL MODEL
A cold HTTPS request pays round trips before any application work happens: DNS, then a TCP handshake, then the TLS 1.3 handshake (1 RTT, with ALPN choosing h2 or http/1.1), then the request itself. HTTP/3 folds transport and TLS into one QUIC handshake over UDP. Every later request to the same origin should ride an already-open connection from the client's pool, multiplexed as a stream on HTTP/2 or HTTP/3. Most "slow API" and "random ECONNRESET" problems are really pool problems: a new client per request, a body never drained, a pool too small, or an idle timeout longer than the server's.
HOW IT FITS TOGETHER
- Client → DNS resolverResolve api.example.com (A/AAAA)Answers are cached for their TTL by the OS, browser and runtime.
- DNS resolver → ClientEdge IP addresses
- Client → CDN edgeTCP SYN
- CDN edge → ClientSYN-ACK (1 RTT spent)
- Client → CDN edgeClientHello: SNI, ALPN h2, key_share
- CDN edge → ClientServerHello + encrypted cert, FinishedEverything after ServerHello is encrypted, including the certificate.
- Client → CDN edgeFinished + GET /quotes on stream 1The request rides with the client Finished: 2 RTT total before the server sees it.
- CDN edge → Origin serverCache miss: forward on pooled connectionThe edge keeps warm connections to the origin, so origin TLS cost is amortised.
- Origin server → CDN edge200 + Cache-Control, ETag
- CDN edge → Client200 (cached per s-maxage)
- CDN edge → ClientAlt-Svc: h3 advertises HTTP/3A later connection may switch to QUIC; the open connection stays in the pool.
- DNS lookuptime_namelookup. Near zero when cached; slow or failing resolvers show up here.
- TCP connecttime_connect. One RTT. Zero on a reused connection.
- TLS handshaketime_appconnect. One more RTT in TLS 1.3, two in TLS 1.2. Includes ALPN.
- Request senttime_pretransfer. Headers and body leave the client.
- First bytetime_starttransfer (TTFB). Server think time plus one RTT; the part your backend owns.
- Body completetime_total. Size, bandwidth and congestion window; the connection then returns to the pool.
- HTTP semanticsMethods, status codes, headers, caching. Identical across all three versions (RFC 9110).
- FramingHTTP/1.1: text, one request at a time per connection. HTTP/2 and HTTP/3: binary frames with compressed headers (HPACK, QPACK).
- MultiplexingHTTP/1.1: none, so clients open several connections per origin. HTTP/2: streams on one TCP connection. HTTP/3: native QUIC streams.
- TLSHTTP/1.1 and HTTP/2: TLS over TCP, ALPN selects http/1.1 or h2. HTTP/3: TLS 1.3 integrated into the QUIC handshake, ALPN h3.
- TransportTCP for HTTP/1.1 and HTTP/2: one lost packet stalls every stream. QUIC over UDP for HTTP/3: loss stalls only the affected stream.
- IPSame for all. QUIC can migrate a connection across IP changes, such as Wi-Fi to cellular.
KEY TERMS
- TLS 1.3 1-RTT handshake
- The ClientHello carries a key share, so after one round trip both sides have keys and the client can send its request with its Finished message. TLS 1.2 needed two round trips.
- 0-RTT early data
- On resumption, a client can send a request in its first flight under a pre-shared key. Early data is not forward secret and can be replayed across connections, so only idempotent requests belong there. Servers can answer 425 Too Early to force a retry after the handshake (RFC 8470).
- ALPN
- A TLS extension in which the client lists protocols (h2, http/1.1) and the server picks one. This is how HTTP/2 is negotiated without an extra round trip. HTTP/3 is discovered separately, usually via an Alt-Svc response header.
- HTTP/2 multiplexing
- Many concurrent requests share one TCP connection as interleaved streams. This removes application-level head-of-line blocking, but TCP still delivers bytes in order, so one lost packet stalls all streams.
- HTTP/3 and QUIC
- QUIC runs over UDP, implements reliability and congestion control per connection with per-stream ordering, and combines transport and TLS 1.3 handshakes into one round trip. Some networks block or throttle UDP, so clients race or fall back to TCP.
- Connection reuse
- Clients pool idle connections per origin and reuse them for later requests, skipping DNS, TCP and TLS. Reuse fails when you create a client per request, leave response bodies unread, or keep idle sockets longer than the server or load balancer does.
- CDN and shared caching
- Requests usually terminate TLS at a nearby edge. Cache-Control s-maxage governs shared caches separately from max-age in browsers, and ETag/If-None-Match turns a refetch into a 304 without a body.
IN YOUR STACK
JavaScript · fetch (browser, Node, Deno) Client
export async function getJSON(url, { timeoutMs = 5_000, signal } = {}) {
const signals = [AbortSignal.timeout(timeoutMs)];
if (signal) signals.push(signal); // e.g. a component unmount or user cancel
const res = await fetch(url, {
signal: AbortSignal.any(signals),
headers: { Accept: "application/json" },
});
if (!res.ok) {
await res.body?.cancel(); // free the connection instead of leaving it half-read
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json(); // the signal still applies while the body streams
}
try {
const quote = await getJSON("https://api.example.com/quotes/42");
} catch (err) {
if (err.name === "TimeoutError") reportTimeout();
else if (err.name !== "AbortError") throw err;
}- fetch has no default timeout. AbortSignal.timeout rejects with a TimeoutError DOMException; a manual abort gives AbortError.
- In browsers the timer counts active time only: it pauses in the back-forward cache and in suspended workers.
- Browsers own the connection pool and protocol choice; you cannot tune keep-alive from page code.
TypeScript · Node.js undici Agent Client
import { Agent, fetch } from "undici";
// One Agent per process or per upstream: it owns the connection pool.
const pricing = new Agent({
connect: { timeout: 2_000 }, // TCP + TLS handshake, ms
headersTimeout: 5_000, // until response headers arrive
bodyTimeout: 10_000, // while receiving body data
keepAliveTimeout: 4_000, // idle socket lifetime; keep below the server's
connections: 64, // max sockets per origin
});
export async function getQuote(id: string) {
const res = await fetch(`https://pricing.internal/quotes/${id}`, {
dispatcher: pricing,
signal: AbortSignal.timeout(8_000), // overall deadline across all phases
});
if (!res.ok) {
await res.body?.cancel();
throw new Error(`pricing ${res.status}`);
}
return res.json();
}- headersTimeout and bodyTimeout default to 300 s and connect timeout to 10 s: far too long for service-to-service calls.
- If the client keeps idle sockets longer than the server's keep-alive, it reuses a socket the server just closed and gets ECONNRESET or "other side closed".
- Import fetch from the same undici package as Agent; pairing an npm undici dispatcher with Node's bundled fetch can break on version skew.
Go · net/http Transport Client
var client = &http.Client{
Timeout: 10 * time.Second, // whole exchange, including reading the body
Transport: func() *http.Transport {
t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConnsPerHost = 32 // default is 2, which churns busy upstreams
t.ResponseHeaderTimeout = 5 * time.Second
return t
}(),
}
func getQuote(ctx context.Context, id string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://pricing.internal/quotes/"+id, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body) // drain to EOF so the connection is reused
return nil, fmt.Errorf("pricing: %s", resp.Status)
}
return io.ReadAll(resp.Body)
}- http.DefaultClient has no timeout. Set Client.Timeout or pass a context deadline on every request.
- Clone DefaultTransport rather than building a zero Transport, which would lose the dial timeout, keep-alive, proxy and HTTP/2 defaults.
- A body that is not read to EOF and closed prevents keep-alive reuse and leaks connections under load.
Python · httpx Client
import httpx
# One client per process: it owns the pool. A client per request defeats keep-alive.
client = httpx.Client(
base_url="https://pricing.internal",
timeout=httpx.Timeout(5.0, connect=2.0, pool=1.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=30),
http2=True, # requires: pip install 'httpx[http2]'
)
def get_quote(quote_id: str) -> dict:
try:
resp = client.get(f"/quotes/{quote_id}")
resp.raise_for_status()
return resp.json()
except httpx.TimeoutException as exc: # Connect/Read/Write/PoolTimeout
raise UpstreamTimeout(type(exc).__name__) from exc- Defaults to 5 s of network inactivity for each of connect, read, write and pool. Read is an inactivity timeout, not a total deadline; a slow drip can run indefinitely.
- PoolTimeout means every connection is busy. Raising it hides a saturated pool; look at max_connections and upstream latency instead.
- HTTP/2 is off by default; check response.http_version to see what was actually negotiated.
Rust · reqwest 0.13 Client
use std::time::Duration;
// Build once and clone freely: Client is an Arc around a shared pool.
pub fn build_client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(2))
.read_timeout(Duration::from_secs(5)) // per read, resets on progress
.timeout(Duration::from_secs(10)) // whole request including body
.pool_idle_timeout(Duration::from_secs(60))
.pool_max_idle_per_host(32)
.build()
}
pub async fn get_quote(client: &reqwest::Client, id: &str) -> reqwest::Result<String> {
client
.get(format!("https://pricing.internal/quotes/{id}"))
.send()
.await?
.error_for_status()?
.text()
.await
}- No total timeout is set by default; timeout() spans connect through the end of the body.
- Use error_for_status() deliberately: send() returns Ok for 4xx and 5xx responses.
- Client already wraps an Arc, so share it by cloning rather than constructing one per call.
Java · java.net.http.HttpClient Client
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class PricingClient {
// One client per application: it owns the connection pool.
private static final HttpClient CLIENT = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // default; falls back to HTTP/1.1
.connectTimeout(Duration.ofSeconds(2))
.build();
static String getQuote(String id) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://pricing.internal/quotes/" + id))
.timeout(Duration.ofSeconds(5))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) throw new IllegalStateException("pricing " + response.statusCode());
return response.body();
}
}- Without a request timeout, send() can block forever. Expiry throws HttpTimeoutException; the JDK implementation stops the timer once the body is consumed.
- Since Java 26 (JEP 517), HTTP/3 is opt-in via Version.HTTP_3; HTTP/2 remains the default.
- Connection pools are per HttpClient instance and not shared, so creating clients per request discards warm connections.
Shell · curl -w Client
FMT='%{http_version} conns=%{num_connects} dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n'
# Two URLs in one invocation share curl's connection pool:
# the second line should show conns=0 and near-zero tcp/tls.
curl -sS --connect-timeout 2 --max-time 10 -w "$FMT" \
-o /dev/null https://example.com/ \
-o /dev/null https://example.com/?again
# Force a protocol to compare (HTTP/3 needs a curl built with QUIC support):
curl -sS --http1.1 -o /dev/null -w "$FMT" https://example.com/
curl -sS --http3 -o /dev/null -w "$FMT" https://example.com/- Timing variables are cumulative from the start of the transfer, so TLS cost is time_appconnect minus time_connect.
- --connect-timeout bounds only the connection phase; --max-time bounds the whole operation.
- Run it from the same network as the failing client: DNS and RTT from your laptop say little about a pod in another region.
WHERE IT BITES
- Creating an HTTP client per request. Every call pays DNS, TCP and TLS again, and under load you exhaust ephemeral ports or the upstream's connection limit.
- Relying on default timeouts: fetch and Go's DefaultClient have none, undici waits 300 s for headers, and Java blocks forever without a request timeout. A hung upstream then holds every worker.
- Keeping idle connections longer than the server or load balancer does. The next request lands on a socket the server already closed and fails with a reset; set the client idle timeout below the server's.
- Retrying a timed-out non-idempotent request without an idempotency key. The timeout tells you the client stopped waiting, not that the server did nothing.
- Allowing 0-RTT for state-changing requests. Early data can be replayed, so restrict it to safe, idempotent methods.
- Assuming HTTP/2 removed head-of-line blocking. It removed it at the HTTP layer; on lossy mobile links TCP still stalls every stream, which is the problem HTTP/3 addresses.
CLOSE THE AI. EXPLAIN THIS.
A service makes 200 requests per second to one upstream and p99 latency jumps whenever traffic spikes, while the upstream's own p99 stays flat. Which phases of the request lifecycle would you measure, and what pool and timeout settings would you check first?WHEN IT BREAKS IN PRODUCTION
SOURCES
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 (opens in new tab)Checked
- RFC 8470: Using Early Data in HTTP (opens in new tab)Checked
- RFC 9113: HTTP/2 (opens in new tab)Checked
- RFC 9114: HTTP/3 (opens in new tab)Checked
- MDN: HTTP caching (opens in new tab)Checked
- curl man page (--write-out) (opens in new tab)Checked
Explainer reviewed