Routing WebSocket Clients to the Nearest Region #

You deploy a second region in Sydney and Australian users still connect to Ireland. DNS says it is routing by latency, the health checks are green, and the round-trip time in your dashboard has not moved. The cause is almost always the same: DNS routes the resolver, not the user, and a corporate or public resolver in a third location makes that decision on everyone’s behalf.

Routing long-lived connections has properties that HTTP routing does not, and the differences decide which mechanism you should use.

Root cause #

Three mechanisms are available, and each fails differently for WebSockets.

Latency-based DNS. The provider measures round-trip times from resolvers to regions and returns the closest. It works well when users use their ISP’s resolver and poorly when they use a centralised public resolver — with EDNS Client Subnet the provider gets a hint about the real client network, but not every resolver sends it and not every provider honours it. The other limitation is that DNS decides only at connect time; a connection already established is unaffected by any later change.

Anycast. The same IP is announced from multiple locations and BGP routes each packet to the topologically nearest one. Routing is fast and needs no DNS TTL, but BGP can re-converge mid-session and move your packets to a different site — which for a stateless UDP service is fine and for a TCP connection means the connection dies. Anycast for WebSockets requires that any site can serve any connection, or that re-convergence is rare enough to treat as a fault.

Client-side probing. The client measures actual connect latency to each regional endpoint and picks the fastest. It is the only mechanism that measures what you actually care about — the user’s real round trip to the real endpoint — but it costs a probe round trip on first load and needs somewhere to cache the result.

For a WebSocket the routing decision is made once and lives for hours, so the cost of measuring properly is amortised over a long session. That tilts the answer towards probing more than it would for HTTP.

Three routing mechanisms for long-lived connections A comparison of latency-based DNS, EDNS client subnet, anycast, client-side probing and GeoIP redirection across who the routing decision is actually made for, the mid-session risk, and the cost. Three routing mechanisms for long-lived connections Decides for Mid-session risk Cost Latency DNS the resolver none free DNS + EDNS subnet client network none free Anycast the packet BGP re-converge kills TCP infra Client probing the actual user none one round trip GeoIP redirect IP database guess none free Only client probing measures the thing you care about; only anycast can move a live connection, which is why it needs care
DNS is the cheap default and probing is the accurate one. Most deployments should use DNS first and probe when it is demonstrably wrong.

Resolution #

Use DNS as the default and let the client correct it. A short probe on first load, cached for a day, gets the accuracy of measurement without paying for it on every connection.

const REGIONS = [
{ id: 'eu-west-1', url: 'wss://eu.ws.example.com/ws' },
{ id: 'us-east-1', url: 'wss://us.ws.example.com/ws' },
{ id: 'ap-southeast-2', url: 'wss://ap.ws.example.com/ws' },
];

const PROBE_TIMEOUT_MS = 2_500;
const PIN_TTL_MS = 24 * 60 * 60 * 1000; // re-probe daily
const RECONNECT_PIN_MS = 60_000; // stay put across a brief reconnect

interface Pin { regionId: string; url: string; chosenAt: number; }

function readPin(): Pin | null {
try {
const raw = localStorage.getItem('ws.region');
if (!raw) return null;
const pin = JSON.parse(raw) as Pin;
return Date.now() - pin.chosenAt < PIN_TTL_MS ? pin : null;
} catch { return null; }
}

/** Measure real connect time to each region; the winner is the one that answers first. */
async function probe(): Promise<Pin> {
const attempts = REGIONS.map((region) => new Promise<Pin>((resolve, reject) => {
const started = performance.now();
// A tiny /probe endpoint that accepts the upgrade and closes immediately —
// this measures TCP + TLS + upgrade, which is exactly what a real connect costs.
const socket = new WebSocket(region.url.replace(/\/ws$/, '/probe'));
const timer = setTimeout(() => { socket.close(); reject(new Error('timeout')); }, PROBE_TIMEOUT_MS);
socket.onopen = () => {
clearTimeout(timer);
socket.close(1000, 'probe');
resolve({ regionId: region.id, url: region.url, chosenAt: Date.now() });
};
socket.onerror = () => { clearTimeout(timer); reject(new Error('failed')); };
}));

// Promise.any resolves with the FIRST success and ignores failures, so an
// unreachable region cannot delay or break the selection.
return Promise.any(attempts);
}

export async function endpointFor(isReconnect: boolean): Promise<string> {
const pinned = readPin();

// During a reconnect, prefer the region we were just on: session resume and
// any per-region state only work if we land back where we were.
if (pinned && (isReconnect || Date.now() - pinned.chosenAt < RECONNECT_PIN_MS)) return pinned.url;
if (pinned) return pinned.url;

try {
const chosen = await probe();
localStorage.setItem('ws.region', JSON.stringify(chosen));
return chosen.url;
} catch {
return REGIONS[0].url; // every probe failed: fall back to DNS
}
}

On the DNS side, keep the record TTL short — 30 to 60 seconds — so a regional evacuation propagates quickly. The usual argument against short TTLs is query cost, which is negligible here because a WebSocket resolves once and then holds the connection for hours.

resource "aws_route53_record" "ws_eu" {
zone_id = var.zone_id
name = "ws.example.com"
type = "A"
set_identifier = "eu-west-1"
ttl = 60 # short: evacuation must propagate fast

latency_routing_policy { region = "eu-west-1" }
health_check_id = aws_route53_health_check.ws_eu.id
records = [var.eu_ip]
}

The health check is what makes evacuation automatic, and it must test something meaningful. An HTTP 200 from a load balancer proves the load balancer is up; a check that completes a real WebSocket upgrade proves the thing users actually need.

First connect versus reconnect On first load the client probes every region in parallel and pins the one that answers first for a day; subsequent connects and reconnects go straight to the pinned region so session resume continues to work. First connect versus reconnect Client DNS Region no pin — probe all regions parallel /probe upgrades ap-southeast-2 answers first pin stored for 24h connect to pinned region after a drop: reconnect to the SAME region Reconnects must not re-probe — landing in a different region discards any resumable session state
Probe once, pin for a day, and never re-probe mid-session. The pin is what makes resume possible.

Verification #

Check what users actually get, from where they actually are.

# What does a resolver in another country receive?
dig +short ws.example.com @1.1.1.1
dig +short ws.example.com @8.8.8.8

# Does the provider honour EDNS Client Subnet?
dig +subnet=203.0.113.0/24 ws.example.com

# Measure the real cost: connect time, separately from message round trip
time npx wscat -c wss://ap.ws.example.com/probe --no-color

Synthetic probes from each region — one connect and one round trip per minute — give you the trend, and the number worth alerting on is connect time rather than message latency. A routing regression shows up there first, because the handshake pays several round trips and a message pays one.

The distribution matters more than the average. If p50 connect time is fine and p95 is triple, a subset of users is being routed to a distant region — usually the ones behind a public resolver, which is exactly the failure DNS-based routing has.

Connect cost is where regional routing pays off, and it is worth separating from message latency because the handshake pays several round trips where a message pays one.

Time to a usable connection, by distance Connection setup costs roughly three round trips, so a client routed to a distant region waits several hundred milliseconds before the first message can flow, while a nearby region is ready in well under a hundred. Time to a usable connection, by distance one TCP round trip plus two for TLS 1.3 and the upgrade TCP handshake TLS + upgrade Server accept Nearest region 54 ms 82 ms 164 ms One continent away 258 ms 252 ms 504 ms Half the world away 768 ms
Routing to the wrong region costs three round trips before anything happens — and pays it again on every reconnect.

Operational checklist #

FAQ #

Why do some users still get the wrong region? #

Because DNS routes resolvers. A user in Melbourne on a public resolver whose nearest node is in Singapore gets whatever is closest to Singapore, and neither your provider nor the user can tell. EDNS Client Subnet helps where it is supported, and client-side probing sidesteps the problem entirely — which is the main argument for paying its one-round-trip cost.

Is anycast better for WebSockets? #

It routes faster and needs no TTL, but it introduces a risk HTTP does not have: if BGP re-converges mid-session, packets for an established TCP connection arrive at a different site and the connection dies. Providers who run anycast for TCP mitigate this with careful announcement policy, and re-convergence is rare — but “rare” across millions of connection-hours is a steady background of unexplained 1006 closes. Use it when your provider handles the stickiness for you.

Should I route by IP geolocation instead? #

GeoIP tells you roughly where an address is registered, which correlates loosely with network distance and not at all with routing quality. A VPN, a mobile carrier’s central egress, or a corporate proxy makes it actively wrong. It is a reasonable last-resort fallback when no probe result is available and DNS is not latency-aware, but it should never be the primary mechanism.

How do I move users out of a failing region? #

Fail its health check so DNS stops handing it out, then drain its connections deliberately with 1001 so clients reconnect promptly. The receiving regions must have headroom for the surge — the arithmetic and the jitter requirement are in multi-region and edge WebSocket delivery, and the drain mechanics are the same as in draining WebSocket connections during deploys.

Does a CDN help route WebSockets? #

Only if it terminates them at the edge. A CDN that passes the connection through to your origin has moved nothing — every byte still travels the full distance. An edge platform that terminates TLS and the upgrade close to the user cuts connection setup substantially, which is the largest single win available for distant users, but message latency is unchanged unless the room state also lives at the edge.

Back to Multi-Region & Edge WebSocket Delivery