Connection Pooling and Keep-Alive for Webhook Senders

Your dispatcher’s flame graph is dominated by ssl_do_handshake and your egress NAT gateway is reporting port exhaustion — you are opening a fresh TLS connection for every webhook you send. This page fixes that specific problem, and it belongs to webhook timeout and connection management. Read it alongside setting connect and read timeouts: pooling makes the connect budget almost irrelevant in steady state and critically important during a cold start, so the two settings are chosen together.

The arithmetic is unforgiving. A TLS 1.3 handshake to a public endpoint costs roughly 90–150 ms of wall clock and two round trips, plus CPU for the key exchange, plus an ephemeral source port held in TIME_WAIT for up to 60 seconds after close. At 2,000 deliveries per second, non-pooled delivery burns 120,000 ports per minute against a 28,000-port NAT allocation. You do not run out of bandwidth; you run out of ports, and the symptom looks like random connect failures to healthy endpoints.

Prerequisites

Step 1: Prove you are paying a handshake per delivery

Measure before you tune. Two signals are conclusive: setup time that does not fall to zero on repeat requests, and a large TIME_WAIT population on the dispatcher host.

Cold delivery versus pooled delivery The first delivery pays DNS, TCP and TLS setup; the second reuses the pooled connection and sends the request immediately. Dispatcher process consumer.example DNS lookup and TCP SYN, 60 ms TLS ClientHello and key exchange, 90 ms POST /hooks, delivery 1 200 OK, Connection: keep-alive socket returned to the pool POST /hooks, delivery 2, no setup at all 200 OK Delivery 2 saves 150 ms, two round trips, and one ephemeral source port.
Every delivery after the first should look like the bottom half of this diagram; if setup time reappears on each event, the pool is not being reused.
# 1. Does setup time vanish on the second request to the same host?
curl -s -o /dev/null \
  -w 'run1 tcp=%{time_connect} tls=%{time_appconnect}\n' \
  https://consumer.example/hooks
# Then from inside the dispatcher, compare against the per-phase histogram:
# a healthy pooled fleet reports time_appconnect ~= 0 for >90% of deliveries.

# 2. Is the host churning sockets?
ss -tan state time-wait | wc -l          # thousands => connections are not reused
ss -tan state established '( dport = :443 )' | wc -l   # should be stable, not spiky

A TIME_WAIT count in the thousands on a dispatcher node is the clearest possible evidence: those are connections you opened and closed rather than kept.

Step 2: Hold one client per process and size the global pool

The single most common cause of zero reuse is constructing the client inside the delivery function. Every httpx.AsyncClient(...) (or requests.post(...), which builds a throwaway session internally) creates a fresh pool that is discarded when the call returns.

import asyncio
import os

import httpx

_client: httpx.AsyncClient | None = None
_client_lock = asyncio.Lock()


def _global_pool_size() -> int:
    """Leave headroom for logs, DB pools, and the broker connection."""
    soft_fd_limit = int(os.environ.get("DISPATCH_FD_LIMIT", "4096"))
    return min(400, max(32, (soft_fd_limit - 512) // 4))


async def get_client() -> httpx.AsyncClient:
    global _client
    if _client is not None:
        return _client
    async with _client_lock:
        if _client is None:
            size = _global_pool_size()
            _client = httpx.AsyncClient(
                limits=httpx.Limits(
                    max_connections=size,
                    max_keepalive_connections=size // 2,
                    keepalive_expiry=30.0,
                ),
                timeout=httpx.Timeout(connect=3.0, write=5.0, read=10.0, pool=2.0),
                follow_redirects=False,
            )
    return _client


async def shutdown_client() -> None:
    global _client
    if _client is not None:
        await _client.aclose()
        _client = None

max_connections is a resource guard, not a throughput dial. It must stay comfortably below the process file-descriptor limit because every connection is an FD, and hitting EMFILE takes down unrelated work in the same process — the broker consumer, the metrics exporter, the log writer. max_keepalive_connections is the subset allowed to linger while idle; setting it equal to max_connections means a burst to 400 destinations leaves 400 idle sockets parked for the whole expiry window.

Call shutdown_client() from your process shutdown hook. Sockets abandoned at exit are closed by the kernel with an RST, which the consumer logs as a client error and, if they alert on it, pages someone.

Step 3: Cap connections per host against worker concurrency

A global cap alone leaves the pool first-come-first-served, and the endpoint that holds connections longest wins. This is the noisy-neighbour problem, and it is entirely mechanical: an endpoint answering in 25 s occupies a connection 125 times longer per delivery than one answering in 200 ms.

Uncapped shared pool versus per-host caps Without a per-host ceiling one slow endpoint holds almost every connection; with a ceiling each destination keeps a guaranteed allowance. No per-host cap Per-host cap of 8 Shared pool: 24 connections allocated first come, first served Shared pool: 24 connections no host may exceed 8 leases slow.example holds 21 of 24 acme.example holds 2, queueing beta.example holds 1, starving slow.example capped at 8 acme.example free to reach 8 beta.example free to reach 8 Hold time, not request rate, is what lets one destination monopolise a shared pool.
A per-host ceiling converts a first-come-first-served pool into a fair one without reducing total capacity for well-behaved destinations.

httpx has no native per-host limit, so enforce it as an admission gate keyed by host — the same mechanism that protects the worker pool:

import asyncio
from collections import defaultdict

MAX_PER_HOST = 8
_host_gates: dict[str, asyncio.Semaphore] = defaultdict(
    lambda: asyncio.Semaphore(MAX_PER_HOST)
)


async def with_host_cap(host: str, coro_factory, *, wait: float = 2.0):
    """Run coro_factory() holding at most MAX_PER_HOST slots for `host`."""
    gate = _host_gates[host]
    try:
        await asyncio.wait_for(gate.acquire(), timeout=wait)
    except asyncio.TimeoutError:
        raise RuntimeError(f"host_pool_saturated:{host}")
    try:
        return await coro_factory()
    finally:
        gate.release()

Choose MAX_PER_HOST from throughput, not intuition: ceil(target_rps_for_host × p95_hold_time). An endpoint you send 20 events per second to, answering at p95 = 250 ms, needs 5 connections; the same rate against a 2-second endpoint needs 40, which is a signal to slow the sender down rather than raise the cap. Worker counts on the other side of the same equation are covered in sizing worker pools for webhook dispatch.

Step 4: Tune keep-alive expiry against peer idle timeouts

Keep-alive is a negotiation you do not control. Your client decides how long to hold an idle connection; the peer decides how long to accept one. When your window is longer than theirs, you will periodically write a request onto a socket the peer has already closed.

Peer infrastructure Typical idle timeout Safe client keepalive_expiry
AWS Application Load Balancer 60 s (default) 30 s
NGINX (keepalive_timeout) 75 s 30 s
Cloudflare / edge proxies 300–900 s 30–60 s
Google Cloud HTTPS LB 610 s 60 s
Serverless function URLs 5–15 s 5 s, or disable keep-alive per host

Thirty seconds is the default that works nearly everywhere, and it is the value to ship before you have per-host data. Where a consumer advertises Keep-Alive: timeout=N, record it and clamp your expiry to N − 5 for that host. Idle NAT gateways add a second constraint: AWS NAT Gateway silently drops idle flows at 350 seconds, so any client expiry above ~300 s produces connections that appear open on your side and do not exist on theirs.

Step 5: Detect and evict stale pooled connections

A pooled connection has a lifecycle, and the failure state is the one nobody plans for: the peer sent a FIN that your process has not read, so the socket looks reusable until you write to it and receive an RST.

Lifecycle of a pooled connection A connection moves between idle and leased, and leaves the pool either because the keep-alive window expired or because the peer half-closed it. New handshake paid Idle in pool keep-alive armed Leased request in flight Stale half-closed Expired idle limit hit Closed socket released pooled acquire release peer FIN idle expiry reached evict close Staleness is only discovered on the next write, which is why one immediate retry is safe and correct.
The Stale branch is the one that surfaces in production as sporadic protocol errors attributed, wrongly, to the consumer.

Because the request never reached the application on the other side, exactly one immediate retry on a fresh connection is safe — this is the standard idempotent-write exception, and it is bounded to one attempt so a genuinely broken peer still fails fast:

import httpx

STALE_ERRORS = (httpx.RemoteProtocolError, httpx.ConnectError)


async def post_with_stale_retry(
    client: httpx.AsyncClient, url: str, payload: dict, headers: dict
) -> httpx.Response:
    """Retry exactly once when a pooled socket turns out to be dead."""
    try:
        return await client.post(url, json=payload, headers=headers)
    except STALE_ERRORS:
        # The peer closed the pooled connection; httpx opens a new one here.
        return await client.post(url, json=payload, headers=headers)

Connection reuse must also be invalidated by configuration changes, not only by time. When an endpoint’s URL, certificate pin or registration record changes, drop its pooled connections immediately — otherwise you keep delivering over a connection that was authorised against the previous destination. Slow endpoints deserve the opposite treatment: shorter allowances and tighter admission, as described in handling slow webhook consumers.

Verification and testing

The metric that proves pooling works is the reuse ratio. Derive it from setup time: a delivery whose TLS setup was zero used a pooled connection.

import time

import httpx
from prometheus_client import Counter, Gauge

DELIVERIES = Counter("webhook_deliveries_total", "Deliveries attempted", ["host"])
NEW_CONNS = Counter("webhook_new_connections_total", "Fresh TCP+TLS setups", ["host"])
POOL_IN_USE = Gauge("webhook_pool_connections_in_use", "Leased connections")


async def instrumented_post(
    client: httpx.AsyncClient, host: str, url: str, payload: dict
) -> httpx.Response:
    started = time.monotonic()
    response = await client.post(url, json=payload)
    setup_ms = (time.monotonic() - started) * 1000
    DELIVERIES.labels(host=host).inc()
    # A cold connection cannot complete in under ~40 ms on the public internet.
    if setup_ms > 40 and response.elapsed.total_seconds() * 1000 < setup_ms - 30:
        NEW_CONNS.labels(host=host).inc()
    POOL_IN_USE.set(len(client._transport._pool.connections))
    return response

Then assert reuse directly in an integration test against a local TLS server, counting accepted connections rather than requests:

import asyncio

import httpx
import pytest


@pytest.mark.asyncio
async def test_pool_reuses_one_connection(local_https_server):
    """20 sequential deliveries must open exactly one connection."""
    limits = httpx.Limits(max_connections=10, max_keepalive_connections=10,
                          keepalive_expiry=30.0)
    async with httpx.AsyncClient(limits=limits, verify=False) as client:
        for i in range(20):
            resp = await client.post(local_https_server.url, json={"n": i})
            assert resp.status_code == 200
    assert local_https_server.accept_count == 1


@pytest.mark.asyncio
async def test_expiry_closes_idle_connection(local_https_server):
    limits = httpx.Limits(max_connections=10, max_keepalive_connections=10,
                          keepalive_expiry=1.0)
    async with httpx.AsyncClient(limits=limits, verify=False) as client:
        await client.post(local_https_server.url, json={"n": 1})
        await asyncio.sleep(1.5)
        await client.post(local_https_server.url, json={"n": 2})
    assert local_https_server.accept_count == 2

accept_count == 1 in the first test is the assertion that fails the moment someone reintroduces a per-request client.

Failure modes and gotchas

Frequently Asked Questions

Does the pool need to be shared across worker processes?

It cannot be, and it does not need to be: sockets belong to a process, so four Gunicorn workers means four independent pools. What this changes is the arithmetic on every limit you set. A per-host cap of 8 across four processes is really 32 connections arriving at that endpoint from one container, so divide the allowance you negotiated by the process count before you configure it.

Are connections pooled per URL or per host?

Per origin, meaning the scheme, host and port triple, so two subscriptions pointing at different paths on the same host share both the connections and the per-host cap. That is usually what you want, but it bites when many tenants register endpoints on one hosting provider's shared domain, because a cap you intended as per-customer silently becomes per-provider. Where that matters, key the admission gate on the endpoint id and treat the host cap purely as a socket guard.

Is the single stale-connection retry safe for a non-idempotent delivery?

It is safe only for the failure you can prove happened before the request was written, which is what a reset on first write to a pooled socket tells you. A failure that arrives after the body has been fully sent is indistinguishable from a response you never read, and retrying it can duplicate the event. Send an idempotency key on every delivery so the ambiguous case becomes harmless rather than relying on classifying the exception correctly.

Is HTTP/2 worth enabling for outbound webhook delivery?

It pays off in one specific shape: heavy fan-out to a small number of hosts, where multiplexing collapses dozens of sockets into one and takes real pressure off an ephemeral port allocation. Against several hundred distinct consumer domains it buys almost nothing, because each origin still needs its own connection and each delivery is a single request. The cost is blast radius, since one connection error now fails every stream in flight on it rather than one delivery.

Why does the reuse ratio collapse after every deploy?

A new process starts with an empty pool, so the first delivery to each destination pays a full handshake again. With several hundred distinct hosts and a rolling restart every hour, that cold start stops being a transient and becomes a permanent tax on your p99. Stagger replica restarts so the fleet is never uniformly cold, and if the histogram still shows it, warm connections to the busiest destinations at startup before taking work.

What happens to a pooled connection when the consumer rotates its TLS certificate?

Nothing visible: the open connection keeps using the session negotiated against the old certificate until it closes, and only new connections see the replacement. That is harmless for a routine rotation and dangerous for a revocation, because you carry on trusting a certificate the consumer has explicitly withdrawn for as long as the keep-alive window lasts. It is the same reason a thirty-second expiry is a better default than a ten-minute one.