Dismantling Domain Sharding for HTTP/3

Your asset pipeline still spreads images and static files across img1., img2., and static. subdomains — a pattern that won benchmarks in 2012 — and now every cold page load burns 300–600 ms in duplicate DNS lookups and TLS/QUIC handshakes while your LCP image queues behind a congestion window that never gets warm.

Root Cause: Sharding Optimizes for a Constraint That No Longer Exists

Domain sharding was a workaround for HTTP/1.1’s concurrency model: one request per connection at a time, and a browser cap of six connections per hostname. Splitting 80 page assets across four hostnames bought 24 parallel lanes instead of 6, and the throughput gain outweighed the setup cost of the extra sockets. Every part of that trade inverts under multiplexed protocols. HTTP/2 runs 100+ concurrent streams over one TCP connection and HTTP/3 does the same over QUIC, so the concurrency ceiling sharding worked around is simply gone — what remains is only the cost column: one DNS resolution, one TCP or QUIC handshake, and one TLS negotiation per shard, roughly 100–300 ms each on a cold mobile connection, paid before the first asset byte moves.

The subtler damage is what sharding does to the transport layer’s learning process. TCP and QUIC congestion control start each connection with a small initial window (typically 10 packets, ~14 KB) and grow it as ACKs confirm capacity. One connection carrying all assets ramps once and then runs at full estimated bandwidth; four connections each start cold, probe the same bottleneck link independently, compete with each other for it, and — because each shard carries a quarter of the bytes — may finish before ever leaving slow start. Sharding also decapitates prioritization: the browser’s scheduler expresses urgency within a connection via HTTP/2 stream weights or RFC 9218 urgency, but it has no mechanism to tell shard A’s connection to yield bandwidth to the LCP image on shard B’s connection. Four independent sockets are four schedulers that cannot see each other, so your hero image races 30 below-the-fold thumbnails at equal footing. On top of that, each extra hostname is a separate 0-RTT session ticket, a separate Alt-Svc discovery, and a separate slot in the partitioned browser cache.

Browsers can rescue part of this automatically. When two shards resolve to the same IP and port and the presented certificate’s SAN list covers both hostnames, HTTP/2 and HTTP/3 clients silently route both origins over one connection — the coalescing behaviour specified in RFC 7540 §9.1.1 and RFC 9114 §3.3 and covered in depth by the parent section. But relying on it as a permanent fix fails four ways: anycast CDNs frequently hand different shards different IPs per resolver, breaking the IP-match precondition intermittently and per-geography; Safari validates SAN coverage more conservatively and opens second connections where Chrome coalesces; any HTTP/1.1 fallback traffic (old proxies, some bots, UDP-blocked-plus-h2-disabled paths) reverts to full sharded behaviour; and coalescing never refunds the per-hostname DNS lookups or reunifies the URL-keyed caches. The durable fix is to make the shards disappear from your URLs, and that is where teams get hurt — because asset URLs are cache keys, and rewriting them invalidates every copy in the browser cache, the CDN edge, and any service worker precache manifest simultaneously.

Before/After: Four Sharded Connections vs One Multiplexed

Sharded vs single-origin connection waterfall under HTTP/3 Top panel: four connection lanes for www, img1, img2, and static hostnames. Each lane spends its opening segment on DNS plus QUIC handshake before short transfer bars begin, and each ramps congestion separately. Bottom panel: one lane for a single origin pays setup once, then multiplexed streams for HTML, the LCP image at urgency 0, and remaining assets fill the entire timeline, finishing about 40 percent sooner. Before — 4 sharded hosts, 4 cold connections 0 ~700 ms ~1400 ms www. img1. img2. static. LCP image done ~1350 ms each shard repeats DNS + QUIC handshake, then ramps cwnd from cold After — 1 origin, 1 handshake, multiplexed streams 0 ~700 ms ~1400 ms cdn. setup ×1 stream: html stream: hero (u=0) streams: css+js streams: imgs (u=4) LCP image done ~810 ms one warm congestion window; scheduler yields low-urgency streams to the hero image DNS + handshake high-urgency stream asset transfer

Minimal Reproduction

A fossilized sharding helper still present in many asset pipelines — round-robin hostname assignment, which also breaks URL stability between renders:

// legacy-shards.js — the 2012 pattern that now taxes every page load.
// Round-robin means the SAME image can hash to img1 on one render and
// img2 on the next, defeating the browser cache on top of the
// handshake cost. Scheduling impact: 4 cold connections, 0 shared
// congestion state, no cross-connection priority signal for the hero.
const SHARDS = ['img1', 'img2', 'img3', 'static'];
let i = 0;
export function assetUrl(path) {
  const host = `${SHARDS[i++ % SHARDS.length]}.example.com`;
  return `https://${host}${path}`;
}

Confirm the tax in three commands:

# 1. Do the shards even share an IP? (If not, coalescing is impossible.)
for h in www img1 img2 static; do echo -n "$h: "; dig +short $h.example.com | head -1; done

# 2. Does one cert cover them all? (SAN list must include every shard.)
openssl s_client -connect www.example.com:443 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName

# 3. Count real connections in Chrome: Network panel → enable the
#    "Connection ID" column → hard reload. Four distinct IDs = four taxes.

Deterministic Fix Protocol

  • [ ] 1. Inventory every shard reference. grep -rhoE 'https?://(img[0-9]+|static|assets[0-9]*)\.' dist/ src/ | sort | uniq -c across built output, templates, CSS url() values, service worker precache manifests, and database-stored rich text. The database-stored URLs are the ones migrations forget.
  • [ ] 2. Bridge with coalescing before touching URLs. Point all shard CNAMEs at one endpoint and deploy a certificate whose SAN covers every shard hostname. This removes the handshake tax for h2/h3 clients on day one with zero URL churn, buying time for the real migration. Verify shared Connection IDs in DevTools.
  • [ ] 3. Verify coalescing held across engines and geographies. Check Safari specifically, and sample dig from multiple resolver locations — if any geography resolves shards to different PoP IPs, coalescing silently degrades there and step 2 is only partial. Field-verify with a RUM check that domainLookupEnd - domainLookupStart is zero for shard-hostname resources.
  • [ ] 4. Rewrite URL generation to the single origin. Replace the shard helper with one constant base. Do it at the pipeline level (framework asset prefix, CMS media base URL, image-CDN domain setting) so shards cannot regrow from templates.
  • [ ] 5. Keep filenames byte-stable through the move. If your bundler embeds the public base URL into content hashes, changing the base regenerates every hash — a full cache wipe at browser and CDN. Configure hashing on content only, and confirm by building with old and new base and diffing the manifest: filenames must be identical.
  • [ ] 6. Warm the CDN under the new hostname before cutover. Edge caches key on hostname + path (plus Vary), so cdn.example.com/a.webp is cold even where img1.example.com/a.webp is hot. Replay the top-N asset URLs against the new hostname per PoP region, then ship the HTML that references them.
  • [ ] 7. Serve 301s from old shards through a long deprecation window. Cached HTML, RSS readers, third-party embeds, and old service worker manifests will request shard URLs for months. Redirects preserve those users at the cost of one hop; killing the DNS records instead breaks them.
  • [ ] 8. Purge stale hints and re-point priorities. Remove preconnect/dns-prefetch for retired shards (each is now a wasted socket — see strategic preconnect usage), and confirm the LCP image carries fetchpriority="high" so the now-unified scheduler actually favours it on the shared connection.
  • [ ] 9. Watch one full cache cycle. Expect a CDN hit-ratio dip and a one-visit browser-cache miss per returning user; both should recover within your asset max-age. Alert if 404s on old shard hostnames rise instead of decaying — that means a reference source from step 1 was missed.

Before/After Metrics

Catalogue site, 92 assets per page, previously sharded across four hostnames; measured p75 on 4G (170 ms RTT) two weeks after full cutover.

Metric Sharded (4 hosts) Single origin Change
DNS lookups per cold load 4 1 −3
TLS/QUIC handshakes per cold load 4 1 −3
Connection setup time (sum, cold) 1,090 ms 260 ms −76%
LCP p75 3.4 s 2.3 s −32%
Bytes in slow-start at LCP time 4 × cold cwnd 1 × warm cwnd unified ramp
0-RTT-eligible return connections 1 of 4 hosts typical all traffic ×4 surface
CDN hit ratio (cutover week) 94% 81% → 95% recovered in 6 days

The LCP gain has two roots: the hero image no longer waits behind a cold handshake to its shard, and the unified connection lets urgency signals demote thumbnail streams that previously competed from separate sockets. The temporary hit-ratio dip is the cost of step 6 done imperfectly — warming deeper than the top-N would have flattened it further.

FAQ

Can I just point all shard hostnames at the same IP and let coalescing fix it without touching URLs?

Only as a bridge. With a shared IP and a SAN-complete certificate, h2/h3 browsers fold the shards onto one connection and the handshake tax disappears — but you still pay a DNS lookup per hostname, Safari coalesces more conservatively than Chrome, HTTP/1.1 fallback clients re-open six sockets per shard, and the URL-keyed caches stay fragmented. Treat coalescing as step one of the dismantling, not the end state.

Will migrating asset URLs to one origin wipe returning visitors’ browser caches?

Yes. The HTTP cache keys on the full URL, so moving img1.example.com/a.webp to cdn.example.com/a.webp is a guaranteed miss even for identical bytes — content-hashed filenames don’t save you because the hostname changed, not the hash. Budget one cold-ish page load per returning visitor, keep old shards answering (301 or mirrored content) through the transition, and schedule the cutover away from peak campaigns.

Should I keep one shard for a separate cookieless domain?

Rarely. HPACK (h2) and QPACK (h3) header compression shrink repeated cookie headers to a few bytes after the first request on a connection, which removes most of the bandwidth argument. A second origin costs a DNS lookup, an extra handshake, a split congestion window, and a second 0-RTT session-ticket surface to manage. Trim the cookies or scope them with Path/Domain attributes instead.


Related