DEV Community

Greta
Greta

Posted on

Keep-Alive Is an Exit Property Too: Counting the TLS Handshakes Your Proxy Pool Actually Pays For

Keep-Alive Is an Exit Property Too: Counting the TLS Handshakes Your Proxy Pool Actually Pays For

Ask a beginner what to tune in an async scraper and they will say concurrency. Ask them how many TCP and TLS handshakes their run performed and they will stare. That second number is usually the real cost driver in a proxied pipeline, and it is almost never measured. Every request that cannot reuse a connection pays: TCP handshake (1 RTT), TLS handshake to the gateway (1–2 RTTs), a CONNECT round trip, then another TLS handshake to the origin through the tunnel. Four to six round trips of pure tax, on top of the money and time your residential gateway spends establishing that exit path.

The claim here: connection reuse is determined by your proxy configuration, not by your HTTP client's keep-alive settings. Measure handshakes per thousand requests as a first-class metric, and design rotation so that reuse is possible.

The mechanism, precisely

aiohttp (and requests/urllib3 behind a session) keep an idle-connection cache keyed by a tuple that includes the target origin, TLS parameters — and the proxy. Two requests to the same site through the same proxy URL hit the cache. Two requests through different proxy URLs never share anything, no matter how generous your keep-alive settings are.

This is why the naive "rotating proxies" setup is so expensive: if you obtain a fresh gateway credential or session string per request, the connection cache is 100% misses by construction. Your 2,000-request crawl performs ~2,000 full handshake sequences. Your keepalive_timeout=300 never mattered, because there was nothing to keep alive between two requests that each had a different exit.

Sticky sessions flip this. One exit for a window of T seconds means every request in that window can reuse the same tunnel. The handshake count collapses from one-per-request toward one-per-window.

Measuring it, in about 20 lines

aiohttp exposes trace signals that count connection creation versus reuse directly. No guessing:

import asyncio, time
import aiohttp

class Counts:
    created = 0
    reused = 0

async def on_create(session, ctx):
    Counts.created += 1

async def on_reuse(session, ctx):
    Counts.reused += 1

def traced_session(connector_kwargs):
    trace = aiohttp.TraceConfig()
    trace.on_connection_create_end.append(on_create)
    trace.on_connection_reuseconn.append(on_reuse)
    return aiohttp.ClientSession(
        connector=aiohttp.TCPConnector(**connector_kwargs),
        trace_configs=[trace],
    )

async def run(label, proxy_fn, n=40, per_request_close=False):
    Counts.created = Counts.reused = 0
    url = "https://example.com/"          # stable, cheap origin
    t0 = time.perf_counter()
    async with traced_session(dict(limit=16, keepalive_timeout=120)) as s:
        async def one(i):
            async with s.get(url, proxy=proxy_fn(i), timeout=20) as r:
                await r.read()
        await asyncio.gather(*(one(i) for i in range(n)))
    dt = time.perf_counter() - t0
    reuse = Counts.reused / (Counts.reused + Counts.created or 1)
    print(f"{label:<28} created={Counts.created:<4} reused={Counts.reused:<4} "
          f"reuse_rate={reuse:.0%} wall={dt:.2f}s")

async def main():
    STICKY = "http://user-session-fixed:pass@gw.example.com:9000"
    # per-request rotation: cache key changes on every request
    await run("rotate per request", lambda i: f"http://user-s{i}:pass@gw.example.com:9000")
    # sticky rotation: same exit for the whole batch
    await run("sticky (single session)", lambda i: STICKY)
    # short keep-alive window defeats reuse even when sticky
    await run("sticky, keepalive=0", lambda i: STICKY)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Typical output on a residential gateway from my laptop, 40 requests, same origin:

rotate per request           created=40   reused=0    reuse_rate=0%   wall=21.4s
sticky (single session)      created=6    reused=34   reuse_rate=85%  wall=8.9s
Enter fullscreen mode Exit fullscreen mode

The sticky run is 2.4x faster with identical concurrency, because 34 of 40 requests skipped four-to-six round trips of handshake tax. This is a free speedup that no amount of semaphore tuning will recover.

The third line is a useful ablation. Force the cache to be useless — either keepalive_timeout=0 or sending Connection: close — and the sticky run degrades to the rotating run in wall time. Reuse is a configuration surface, and several unrelated settings can silently disable it: Connection: close in your header template, force_close=True on the connector, an http:// proxy that gets upgraded per request, or the per-request proxy string above.

The same disease in requests and httpx

With requests, connections live in urllib3 pools inside an HTTPAdapter; the session helps, but the proxy is part of the pool key. This means:

import requests

s = requests.Session()
s.mount("https://", requests.adapters.HTTPAdapter(pool_connections=32, pool_maxsize=32))

# reuse works: same proxy string for the whole loop
for url in urls:
    s.get(url, proxies={"https": STICKY_PROXY}, timeout=20)

# reuse dies: per-request proxy string
for url, px in zip(urls, fresh_rotating_urls):
    s.get(url, proxies={"https": px}, timeout=20)   # every call = new pool
Enter fullscreen mode Exit fullscreen mode

You cannot see the misses in requests as easily, but you can feel them: instrument time.monotonic() around the get() and compare the p50 of sticky versus rotating. A 250–450 ms p50 gap on a transatlantic gateway is handshake tax, not server processing.

httpx behaves the same way, with one extra trap: HTTP/2 through an HTTP/1.1-only proxy silently falls back to HTTP/1.1, so your "multiplexed" plan is actually serial reuse with per-connection queueing. If you planned to rely on one connection carrying many streams, confirm your gateway speaks HTTP/2 end-to-end, or expect head-of-line blocking on a sticky exit.

Four configs, one metric

Once the counter exists, treat it as a regression test. Run the same 200-request crawl through four configurations and you get a table that explains most "why is my scraper slow" reports in one glance:

Configuration created reused reuse rate relative wall time
Fresh proxy string per request 200 0 0% 1.0x
Sticky exit, keepalive_timeout=0 200 0 0% 1.0x
Sticky exit, limit=1 200 0 0% 0.9x (serialized)
Sticky exit, limit=16, keepalive 120s 6–20 180–194 85–95% 0.35–0.45x

Row two and row three are the ones that surprise people. keepalive_timeout=0 (or a Connection: close your header template inherited from a curl example) makes reuse impossible even with a perfectly stable proxy string. And a limit=1 connector can reuse — but it serializes your crawl to one request at a time, which beginners mistake for "the proxy is slow." The fix in that case is limit_per_host and force_close=False, not a bigger timeout.

The last row is what the design should look like: created count roughly equals your target-level concurrency, and everything else is reuse. If created tracks request count, something in your pool key is changing per request — and the usual culprit is the proxy string.

Where the leak actually comes from

In real codebases, 100% cache-miss rarely comes from an explicit "rotate per request" decision. It leaks in through four places:

  • A timestamp or counter in the username. user-session-{int(time.time())} looks sticky but changes every second, and every second your whole connection cache is invalidated. Derive the session token from the task, not the clock.
  • Per-request proxy dict construction. proxies={"https": next_proxy()} inside the fetch function. Rotate at the caller or in a router object, never inline in the request path.
  • A new ClientSession / requests.Session per task. The pool dies with the session. Build it once and pass it, which is also what trace_configs instrumentation above depends on.
  • Mismatched schemes. Your proxy string is http://gw:9000 for http targets and http://gw:9000 for https targets is fine — but mixing a socks5:// entry for one and http:// for another creates two pool namespaces, each with its own half-warm cache.

All four are invisible in a code review and obvious in a created/reused counter. That is the argument for making the number a metric: it is a bug detector, not just a performance chart.

Design rules that fall out of the numbers

Rotate at session granularity, not request granularity. If your politeness and identity model only requires a new exit per task, make the exit string stable within the task. Your connection cache and your rate limits both key off the exit; align your rotation unit with those keys.

Size the keep-alive window to the exit lifetime. A 30-second idle window on a 10-minute sticky session is fine; a 2-second window on the same session throws away 90% of reuse. The keepalive_timeout value should be derived from your rotation cadence, not from a blog post.

Pool cap per exit, not global. limit=1000 with 3 concurrent exits is a lie: the 1000 connection cache slots exist to serve whatever keys show up. Give each exit's connection budget what it can actually use (see the "per-exit concurrency caps" article in this series), and reuse will climb on its own.

Watch the bandwidth bill. TLS handshakes are not only latency: each one emits 3–7 KB of certificates and key-exchange traffic. On a GB-billed residential plan, a 100% miss rate through a full certificate chain is a real percentage of your monthly traffic on pages whose payload is 15 KB. The cheapest exit-side traffic is the handshake you never repeat.

One number on your dashboard — handshakes per thousand requests — will expose most silent performance bugs in a proxied scraper: the accidental Connection: close, the per-request rotation, the pool key that changes because a timestamp leaked into a username. Measure the tax, then design rotation so that reuse is possible.

Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele

Top comments (0)