Unicast vs. Anycast for Webhook Ingress: Cutting Handshake Latency for Global Senders
Compare Unicast and Anycast DNS for webhook ingress. Learn how BGP Anycast, TCP Fast Open, and TLS 1.3 0-RTT eliminate latency for high-volume senders.

Unicast vs. Anycast for Webhook Ingress: Cutting Handshake Latency for Global Senders
Webhook publishers such as Stripe, GitHub, Shopify and Slack deliver event notifications to endpoints all over the world. Each delivery has to resolve DNS, open a TCP connection, complete a TLS handshake and send an HTTP request before your server sees a single byte of payload. For a receiver far from the sender, those handshake round trips are the largest part of the network cost.
Publishers also give you very little time to respond. GitHub expects a 2xx response within 10 seconds and terminates the connection otherwise. Shopify has a one-second connection timeout and a five-second timeout for the whole request, and Slack’s Events API expects a response within three seconds. Stripe retries failed deliveries for up to three days with exponential backoff in live mode.
This article covers what each handshake step costs, how BGP Anycast shortens the path, and where TCP Fast Open (TFO) and TLS 1.3 0-RTT help or hurt. It also corrects some commonly repeated claims about all three.
1. The anatomy of webhook ingress latency
A fresh delivery from a publisher to your endpoint goes through these steps:
DNS lookup → TCP handshake → TLS handshake → HTTP POST → response
| Step | Cost in round trips (RTTs) to your endpoint |
|---|---|
| DNS resolution | Depends on the sender’s resolver and cache, not on your endpoint’s distance |
| TCP three-way handshake | 1 RTT before the client can send data |
| TLS 1.2 full handshake | 2 RTTs |
| TLS 1.3 full handshake | 1 RTT (RFC 8446) |
| HTTP request and first response byte | 1 RTT, plus your processing time |
Take a sender in Dublin and an endpoint in Virginia, where the round trip is typically somewhere around 70–80 ms. Use 75 ms as an illustrative figure and measure your own paths.
- TLS 1.3: TCP (75 ms) plus TLS (75 ms) is 150 ms of setup. The first response byte arrives about 225 ms after the sender starts connecting.
- TLS 1.2: setup is 225 ms and the first response byte arrives at about 300 ms.
These figures exclude DNS and server processing time.
Handshake latency is real, but it is usually not what causes webhook timeouts. A few hundred milliseconds of setup is small against timeouts of 3 to 10 seconds. Timeouts and retry storms are mostly caused by slow application handlers, which section 7 addresses. Shorter handshakes help most with tight limits such as Shopify’s one-second connection timeout, and with the volume of cold connections you absorb during bursts.
Connection reuse also matters. Shopify’s delivery system uses HTTP Keep-Alive to reuse connections to the same host. Where a sender reuses connections, the handshake cost is paid only on cold connections, not on every event.
2. Unicast vs. Anycast
Unicast
With unicast, the endpoint’s IP address maps to one location, such as a single region or load balancer cluster. Every sender, wherever it is, sends packets across the Internet to that location. A sender 50 km away and one 8,000 km away both end up at the same place.
BGP Anycast
With anycast, several sites (points of presence, or PoPs) announce the same IP prefix over BGP. Each network along the way picks a path to the prefix according to its own routing policy. That decision usually depends first on local preference, then on AS-path length. The result is that senders generally reach a topologically close PoP. A few clarifications:
- “Nearest” means topologically nearest, not geographically nearest. Routing policy and peering decide the path, so a sender can land on a PoP that is not the closest one by distance.
- Prefix length: the common rule of thumb is that routes more specific than a /24 (IPv4) or /48 (IPv6) should not be expected to work globally, because many networks filter them. An anycast service therefore announces at least a /24 (IPv4) or /48 (IPv6), not a /32. You need your own address space and AS number for this, or you can use a provider that already operates an anycast network.
- Stability: a USC/ISI study of anycast DNS services found that anycast works well for the vast majority of vantage points. About 1% saw frequent shifts between anycast sites. The study found TCP-level flipping much rarer than UDP flipping, in roughly 0.15% of vantage point and service combinations, though it does occur. If a route shifts mid-connection, the TCP connection breaks. Webhook deliveries are short-lived HTTP requests, so exposure is small, but senders should retry. Stripe, GitHub and Shopify all retry or support redelivery, as does your own fallback logic.
Anycast IPs vs. anycast DNS
The title of this article’s original draft conflates two things:
- Anycast DNS means your authoritative nameservers use anycast. This speeds up name resolution.
- Anycast IP ingress means the webhook endpoint’s own address is anycast. This shortens the TCP and TLS round trips.
You can use either or both. A third option is DNS-based steering, such as latency-based or geo-aware DNS, where each region has its own unicast IP. It is simpler to operate than BGP and has no flip risk, but it depends on the sender’s resolver location and on DNS caching. Managed products such as CDNs and global accelerators give you anycast without running BGP yourself.
3. Withdrawing routes when a PoP is unhealthy
An anycast PoP that keeps announcing its prefix while its local proxy is dead will blackhole the traffic that BGP sends it. Each PoP therefore needs to withdraw its route when it is unhealthy. ExaBGP is one option. It reads route announcements from a helper process, so a health-check loop can drive announcements directly.
#!/bin/bash
# /opt/anycast/announce.sh — emits ExaBGP text-API commands on stdout
PREFIX="198.51.100.0/24" # documentation prefix; use your own
state="withdrawn"
while true; do
if curl -fsS --max-time 2 http://127.0.0.1:8080/healthz >/dev/null; then
if [ "$state" != "announced" ]; then
echo "announce route $PREFIX next-hop self"
state="announced"
fi
else
if [ "$state" != "withdrawn" ]; then
echo "withdraw route $PREFIX next-hop self"
state="withdrawn"
fi
fi
sleep 2
done
Two operational notes. Add hysteresis so a flapping health check does not cause route flaps. And make sure a shared failure, such as a bad deploy, cannot withdraw every PoP at once. BGP convergence takes time, so withdrawal is not instantaneous.
4. TCP Fast Open (TFO)
TFO (RFC 7413) lets a client send data in the SYN packet on repeat connections, saving one round trip. The first connection gets a cookie from the server. Later connections present the cookie and carry data in the SYN. The RFC is an experimental one, and the practical caveats are significant:
- The sender must opt in. The server can only enable its side. If the publisher’s HTTP client does not use TFO, you gain nothing. None of the provider documentation reviewed for this article mentions TFO support, so do not assume it.
- Middleboxes. Some network devices drop TCP packets carrying options they do not understand. Operators report connections that appear to die when TFO is used, so clients commonly fall back to a regular handshake.
- Duplicates. Data in a SYN can occasionally be delivered more than once, so the application must tolerate duplicate requests, which idempotent webhook handling already requires.
Enabling TFO on a Linux ingress node
# /etc/sysctl.d/99-tfo-ingress.conf
# Bitmask: 1 = client, 2 = server, 3 = both
net.ipv4.tcp_fastopen = 3
NGINX also needs TFO enabled on the listen socket:
listen 443 ssl fastopen=256;
listen [::]:443 ssl fastopen=256;
To check whether senders actually use it, read the kernel counters. ss does not show a TFO marker.
nstat -az TcpExtTCPFastOpenPassive TcpExtTCPFastOpenPassiveFail \
TcpExtTCPFastOpenListenOverflow TcpExtTCPFastOpenCookieReqd
A non-zero TCPFastOpenPassive count means clients are completing fast-open connections to you. If it stays at zero, your senders are not using TFO.
5. TLS 1.3 and 0-RTT resumption
TLS 1.3 completes a full handshake in one round trip. With a session ticket from an earlier connection, a client can also send application data in its first flight. This is 0-RTT early data (RFC 8446, section 2.3).
Early data has two limitations:
- It is replayable. An attacker who captures the encrypted early data can resend it, and the server cannot cryptographically tell the copy from the original. The data also lacks forward secrecy.
- Major CDNs treat POST as unsafe for early data. Cloudflare does not enable 0-RTT by default and always rejects requests that are obviously not idempotent, such as POST or PUT.
Webhook deliveries are POST requests, so 0-RTT is the riskiest optimisation here. It is only appropriate if your handler is strictly idempotent. RFC 8470 defines the Early-Data: 1 header, which a proxy adds to forwarded early-data requests, and the 425 Too Early status code that a server returns to refuse them. The original draft’s advice to accept 0-RTT for “idempotent methods (POST ...)” was wrong, because POST is not idempotent by definition.
NGINX configuration
NGINX supports ssl_early_data with OpenSSL 1.1.1 or later, and the NGINX documentation warns that early data requests are subject to replay attacks and should be handled at the application layer using $ssl_early_data.
server {
listen 443 ssl fastopen=256;
listen [::]:443 ssl fastopen=256;
http2 on; # nginx 1.25.1+; older versions use "listen ... http2"
server_name webhooks.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Keep TLS 1.2: Stripe documents TLS 1.2 or higher as its requirement,
# and a TLS 1.3-only endpoint would shut out clients that stop at 1.2.
ssl_protocols TLSv1.2 TLSv1.3;
# Off by default. Enable only on a dedicated server block whose
# handlers are idempotent, and always forward the signal downstream.
ssl_early_data on;
location /webhook {
proxy_set_header Early-Data $ssl_early_data;
proxy_pass http://webhook_backend;
}
}
The original draft set ssl_protocols TLSv1.3 only. That is a risk for a public webhook endpoint, because any sender that cannot negotiate TLS 1.3 will fail to deliver. Enabling both versions and testing each publisher’s delivery is safer.
In an anycast fleet, session resumption only works if any PoP can decrypt a ticket issued by another. This means sharing session ticket keys across PoPs and rotating them regularly.
Replay defence
If you accept early data on webhook endpoints:
- Deduplicate by event ID. Use Stripe’s
evt_…ID or GitHub’sX-GitHub-Deliveryheader with an atomic insert such as RedisSET key 1 NX EX <ttl>. GitHub says redelivery keeps the sameX-GitHub-Deliveryvalue. Stripe retries for up to three days, so the TTL should be at least that (259,200 seconds) for Stripe events. - Use a shared dedupe store. In an anycast fleet a replayed request can land on a different PoP, so a per-PoP cache does not stop a cross-PoP replay.
- Verify signatures. This establishes authenticity, but a replay of a valid signed request still has a valid signature. Signature timestamps limit the replay window but do not remove it.
- Return
425 Too Earlyfrom handlers that cannot guarantee idempotency whenEarly-Data: 1is present.
6. Putting the numbers together
The table shows time to first response byte, excluding DNS and server processing time. The 15 ms anycast RTT is an illustrative assumption, not a measurement.
| Stack | RTT | Calculation | Time to first response byte |
|---|---|---|---|
| Unicast, TLS 1.2 | 75 ms | TCP 1 + TLS 2 + request 1 = 4 RTT | ~300 ms |
| Unicast, TLS 1.3 | 75 ms | TCP 1 + TLS 1 + request 1 = 3 RTT | ~225 ms |
| Anycast, TLS 1.3 | 15 ms | 3 RTT | ~45 ms |
| Anycast + TFO, TLS 1.3 | 15 ms | 2 RTT (ClientHello rides in the SYN) | ~30 ms |
| Anycast + TFO + TLS 1.3 0-RTT | 15 ms | 1 RTT (request in first flight) | ~15 ms |
| Unicast + TFO + TLS 1.3 0-RTT | 75 ms | 1 RTT | ~75 ms |
The last two rows show that the techniques are complementary. Anycast shrinks the RTT itself, while TFO and 0-RTT reduce how many RTTs you pay. Even the best case is one RTT, not zero. The earlier claim of “~0 ms connection overhead” overlooks that the request must still reach the server and the response must come back.
These gains also require sender cooperation. TFO and 0-RTT only apply if the publisher’s client uses them, and TFO and 0-RTT early data are both replayable. Anycast needs no sender support, so it is the most dependable of the three.
7. Acknowledge at the edge, process later
Fast ingress only helps if the application behind it is also fast. If the PoP must wait for a distant database before replying, you have moved the delay rather than removed it. Provider documentation says the same: GitHub and Shopify both recommend queuing payloads and responding quickly, with processing done asynchronously.
A resilient design looks like this:
- Validate and acknowledge at the PoP. Verify the signature against the raw request body, such as the
Stripe-Signatureheader, then durably enqueue the event and return a 2xx. Return the exact status the sender requires, because Shopify requires 200 while GitHub accepts any 2xx. - Buffer in a replicated queue. Use something like Apache Kafka or Amazon Kinesis, or a managed edge queue such as Cloudflare Queues, so events are persisted before you acknowledge them.
- Keep PoP-to-origin connections warm. The PoP can hold persistent connections to the origin, so the long-haul handshake is paid once and not once per webhook.
- Process idempotently. All three publishers can deliver an event more than once. Shopify explicitly notes that duplicate deliveries can occur after network timeouts or retries.
- Reconcile. If you cannot afford to miss events, periodically compare against the publisher’s API. Shopify recommends this, and Stripe lets you query missed events.
8. Measure before and after
You can measure handshake costs from any sender-like location with curl:
curl -so /dev/null --tlsv1.3 \
-w 'dns=%{time_namelookup}s tcp=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s\n' \
https://webhooks.example.com/healthz
Run it from several regions against both your unicast and anycast addresses. Add --tcp-fastopen to test TFO from a client that supports it. Then compare the results with your ingress logs and the kernel counters from section 4 to see which senders benefit.
Conclusion
For globally distributed webhook senders, anycast gives the largest and most dependable latency improvement, because it shortens every round trip and needs no cooperation from the sender. Pair it with route withdrawal on health-check failure, TLS 1.3 alongside 1.2 compatibility, and an edge that acknowledges quickly and queues the work. Add TFO where your senders support it. Enable TLS 1.3 0-RTT only on endpoints whose handlers are strictly idempotent, with a shared deduplication store, and treat it as a niche optimisation rather than a default for POST webhooks.