I've watched the edge traffic graph go vertical enough times to know what panic looks like. During the 2022 World Cup semi-final, we had 1.8 million concurrent viewers jump to 4.3 million in under four minutes after a penalty decision — and that wasn't even the final. That's the reality of live event scaling: you don't get a gentle ramp, you get a cliff. If your event CDN isn't pre-warmed, shielded, and ready to absorb that spike, you won't degrade gracefully, you'll just fail.
The Thundering Herd: Why Origins Die on Live Events
With VOD, your cache warms organically and stays warm. Live is the opposite. Every 2-6 seconds, your packager emits a brand new segment that no edge has ever seen, and suddenly every viewer watching needs that exact same file at the exact same moment. Back in 2018, I was on call for a major US award show where we hadn't enabled collapsed forwarding properly on one mid-tier cluster. The origin was healthy at 12k RPS until the headline performance started, then it was hit with 340k simultaneous conditional requests for the same three segments. It fell over in 11 seconds, and we spent the next six minutes serving stale manifests while it recovered. That’s the thundering herd in action.
The fix isn't just adding more origin capacity — you can't scale an origin fast enough to handle millions of concurrent viewers requesting uncached content in sync. You have to ensure that only one request per segment per PoP goes back to the shield, and everyone else waits. This is where request coalescing (also called collapsed forwarding) and proper cache-control semantics become non-negotiable. I configure all live origins to send explicit Cache-Control: max-age=2, stale-while-revalidate=5, stale-if-error=20 for segments and even tighter for manifests. As defined in the HTTP Working Group Specifications, stale-while-revalidate lets the edge serve an immediately previous segment while it fetches the newest one in the background, which prevents that micro-stall when a segment is 50ms late from the packager. Without it, you’ll see origin RPS correlate 1:1 with viewer count, and no origin survives that.
For live event scaling, your shielding topology matters as much as the caching logic. A flat CDN with 150 PoPs all hitting origin is a recipe for a self-inflicted DDoS. We always run a two-tier shield — edges talk to 3-4 regional shields, shields talk to origin. That reduces origin load by two orders of magnitude before you even tune a cache key. It’s also where you get the most value from your CMAF Packaging for CDN Delivery: Unified Streaming Format for HLS and DASH strategy, because a single CMAF chunk set serves both protocols and doubles your cache hit efficiency at the shield.
# Varnish / Fastly-style collapsed forwarding + shield Grace
sub vcl_recv {
# Only one request for a live segment goes to shield/origin
if (req.url ~ "\.(m4s|ts|mp4)$") {
set req.hash_always_miss = false;
# Enable request coalescing
set req.backend_hint = shield_cluster;
}
}
sub vcl_backend_response {
# Live segments: short TTL, serve stale while fetching fresh
if (bereq.url ~ "\.(m4s|ts|mp4)$") {
set beresp.ttl = 2s;
set beresp.grace = 20s;
set beresp.keep = 60s;
set beresp.http.Cache-Control = "public, max-age=2, stale-while-revalidate=5, stale-if-error=20";
# Don't cache 5xx, but keep serving stale
if (beresp.status >= 500) {
set beresp.ttl = 0s;
set beresp.grace = 20s;
}
}
# Multivariant playlists: 1s TTL, must revalidate
if (bereq.url ~ "\.m3u8$") {
set beresp.ttl = 1s;
set beresp.grace = 2s;
}
}
Prefetching and Mid-Tier Population for Predictable Peak Capacity
Even with perfect coalescing, you’re still reactive — the first request in each PoP still has to travel to the shield and back, adding 40-120ms of latency that stacks up at scale. For a 3-second segment with 2 million viewers, that initial miss can mean tens of thousands of clients blocking on the same origin fetch, which pushes your time-to-first-byte distribution out and triggers player rebuffers. That's why we don't wait for viewers to request segments anymore. For any event CDN expected to exceed 500k concurrent viewers, we implement predictive prefetch at the shield and mid-tier.
The logic is simple and it works because live playlists are predictable. If the player has fetched segment_1234.m4s, it will absolutely need segment_1235.m4s in two seconds. So as soon as the shield fetches 1234, it proactively pulls 1235 and pushes it to the edges that have shown demand for that rendition. I've seen this cut shield-to-edge miss latency by 65% during peak capacity windows. We trigger it off the manifest update, not the segment request, so the content is already warm before the thundering herd even asks for it. This pairs directly with your choice of protocol — if you're debating rendition structure, our DASH vs HLS for CDN Streaming: Protocol Comparison and Selection Guide breaks down why DASH's templated URLs make prefetch slightly cleaner than HLS's explicit segment lists, though both work.
This gets trickier with low-latency modes. When you're delivering partial segments or LL-HLS parts with 200-500ms duration, you can't prefetch whole segments; you have to prefetch parts and you can't tolerate a blocking fetch at all. For events where we've run sub-3-second glass-to-glass, we've moved to a publish-ahead model where the packager pushes available parts to the shield via HTTP/2 push or early hints before the manifest even references them. The web.dev Articles on early hints and 103 responses are a good primer on the underlying mechanism. If you're planning an LL-HLS event at scale, don't try to retrofit normal prefetch — read the Low-Latency HLS Configuration for CDN: Sub-3-Second Live Streaming setup first, because cache TTLs and coalescing behave completely differently when parts are involved. A one-second misconfiguration there will multiply your origin load by 6x.
# ATS / Nginx-style predictive prefetch triggered on manifest refresh
# Executed at shield layer after fetching master.m3u8
map $request_uri $prefetch_trigger {
~*/live/.*\.m3u8$ 1;
}
proxy_cache_lock on;
proxy_cache_lock_timeout 2s;
proxy_cache_use_stale updating error timeout;
# After serving manifest, asynchronously warm next 2 segments
# for each bitrate that had >100 concurrent viewers in last 30s
location ~* /live/(?P\d+p)/seg-(?P\d+)\.m4s$ {
# Background fetch next sequence
post_action @prefetch_next;
}
location @prefetch_next {
internal;
# Extract next sequence number and fire subrequest to origin
# Non-blocking: edge continues serving current segment
proxy_pass http://origin/live/$rendition/seg-${next_seq}.m4s;
proxy_cache_key $scheme$proxy_host/live/$rendition/seg-${next_seq}.m4s;
proxy_cache_valid 200 2s;
# Use background update so client isn't blocked
proxy_background_update on;
}
Modeling Real Headroom for Millions of Concurrent Viewers
You can't just say "we have 100 Tbps of edge capacity" and call it event-ready. Live event scaling is constrained by requests per second, concurrent connections, and cache hit ratio far more than raw bandwidth. I model headroom with a brutally simple formula before every big event: Peak RPS = Concurrent Viewers * (1 / Segment Duration + 1 / Manifest Refresh Rate). For a typical HLS event with 2-second segments and 2-second manifest polling, each viewer generates ~1 RPS. At 3 million concurrent viewers, that's 3 million RPS at the edge. If your edge cache hit ratio (CHR) is 98.5%, your shield still sees 45,000 RPS. If CHR drops to 96% because of poor sharding or query string cache keys, shield RPS triples to 120,000. That delta is what kills you.
Bandwidth is easier to estimate but people still get it wrong by forgetting rendition mix. Don't model at average bitrate — model at the p90 rendition. In practice, 40-50% of your concurrent viewers will be on the top two renditions. We also add a 30% burst buffer for rewinds and DVR window seeks, which create random cache misses for older segments. I learned this the hard way during a football final where the halftime analysis segment caused a 22% spike in traffic for segments that were 15 minutes old and had already been evicted from edge RAM. Now we pin the last 10 minutes of live DVR window in shield cache with a separate, longer TTL.
The numbers below are from three events we ran last year on the same 80-PoP footprint — they show how non-linear the scaling is. Notice how origin offload must stay above 99.2% once you cross 1 million concurrent viewers, otherwise your origin simply can't keep up with segment production. This is why unified CMAF and consistent cache keys are worth the upfront engineering effort. Shaving even 0.3% off your miss rate at 2 million viewers saves you 6,000 origin requests per second.
| Event Size (Concurrent Viewers) | Edge Bandwidth (Tbps) | Edge RPS (Million) | Cache Hit Ratio Required | Origin RPS at Shield |
|---|---|---|---|---|
| 250K (Regional Match) | 1.1 | 0.24 | 97.5% | 6,000 |
| 1M (National Final) | 4.8 | 0.95 | 99.2% | 7,600 |
| 3M (Global Live Event) | 14.2 | 2.85 | 99.6% | 11,400 |
| 5M+ (Peak Capacity Test) | 23.5 | 4.70 | 99.8% | 9,400* |
Putting It Into Practice: Shield Tiers, Collapsing, and Cache-Key Discipline
Modeling headroom's the easy part. Making it survive first contact with two million players hitting play within a 30-second window is where most live events fall over. We've seen it a hundred times: your dashboards show 94% cache hit ratio in staging, then on event night your origin's getting 18,000 req/s and melting. The gap's almost always the same three things — no real shield hierarchy, no collapsed forwarding, and a sloppy cache key.
Don't let 250+ edge PoPs talk directly to your origin. For any event over ~500k concurrent viewers, you want at minimum a two-tier shield, and for 2M+ we always run three tiers. Edge -> Regional Mid-Tier -> Origin Shield -> Origin. The trick isn't just adding layers, it's forcing fan-in. We pin the Origin Shield to two PoPs — typically one primary, one failover — in the region closest to your packager. So instead of 150 mid-tiers thundering your origin for the same 4-second segment, you've got exactly 1 request per segment per rendition hitting your origin. That takes you from 15k origin RPS to maybe 12-25 RPS for a 6-rendition ABR ladder. That's how you stay alive.
The second non-negotiable is request collapsing (also called coalescing). When 80,000 viewers in Chicago all request /live/mlb/720p/seg_18432.m4s within 200ms and it's a miss, you don't want 80,000 forwards. You want 1 forward and 79,999 waits. Most CDNs have this, but it's often off by default or tuned with a tiny 100ms window. For live VOD segments we set a collapse window of 1.5-2s and pair it with aggressive cache-lock timeouts. And normalize your cache key — strip every query param your player appends for analytics (?_t=12345&player=shaka), lower-case it, sort it. One stray cache-buster param will silently turn your 99% hit ratio into 40% and you'll never see it until origin dies. If you're still designing your ingest and packaging flow, nail this down early — see our breakdown of CDN Live Streaming Architecture: Ingest, Transcode, Package, and Deliver at Scale for how we structure packager outputs for optimal caching.
# VCL-style shield + collapsed forwarding for live segments
# Applied on Mid-Tier / Origin Shield
sub vcl_recv {
# 1. Normalize cache key - strip player heartbeats & cache busters
set req.url = regsuball(req.url, "\?.*$", "");
set req.url = regsub(req.url, "/live/([^/]+)/([^/]+)/seg_.*", "/live/\1/\2/");
# 2. Force shield routing - only shield POPs hit origin
if (req.url ~ "^/live/") {
set req.backend_hint = origin_shield.backend();
# 3. Enable collapsed forwarding (request coalescing)
set req.hash_always_miss = false;
}
}
sub vcl_hit {
# Serve stale while shield revalidates - critical for herd protection
if (obj.ttl >= 0s) { return (deliver); }
if (obj.ttl + obj.grace > 0s) {
return (deliver);
}
}
sub vcl_backend_response {
# Live segments: 4s segments, 2s grace + 10s stale-while-revalidate
if (bereq.url ~ "^/live/") {
set beresp.ttl = 4s;
set beresp.grace = 10s;
set beresp.keep = 60s;
# Collapse window: hold other requests for 2s while fetching
set beresp.ttl = 2s; # actual collapse lock handled by shield
}
}
sub vcl_miss {
# Do not queue indefinitely - fail fast on origin error
set req.grace = 15s;
return (fetch);
}
Advanced Patterns: Surviving Rewind Storms, CMAF Latency, and Shield Failover
Once collapsing and shielding are solid, the next failure mode isn't the live edge — it's time-shift. The moment you enable 2-hour DVR, you've just created a long-tail cache problem. We learned this the hard way on a 1.8M viewer football final: 70% of traffic was on the live edge (last 3 segments), but 30% was scattered across the last 90 minutes as people rewound goals. That scattershot pattern nukes your hit ratio if you treat DVR the same as live. Our pattern now is to separate cache tiers logically: live edge segments get tiny 2-4s TTLs with stale-while-revalidate, while DVR segments get 5-15 minute TTLs and are cached mostly at the mid-tier. Pushing CDN DVR Functionality at the Edge: Time-Shifted Viewing for Live Streams to the edge with proper origin range-request support means rewinds don't collapse back to origin — they hit mid-tier and stay there.
For low-latency, everything gets tighter. If you're running Low-Latency DASH with CMAF Chunks: Achieving Near-Real-Time CDN Delivery with 200ms CMAF chunks and 1.5s end-to-end latency targets, you can't afford a 2s collapse window — you'll add glass-to-glass delay. We switch to chunk-aware collapsing: cache at the chunk (partial segment) level, not the full segment, with 250-400ms lock windows and a prefetch of the next chunk. Your shield must support HTTP/1.1 chunked transfer or HTTP/2 push for partial objects, otherwise you'll buffer the whole 4s segment before delivering the first chunk and you've just killed your latency win. And please, test stale-if-error properly. When your packager hiccups for 4 seconds and returns 502s, you want your shield serving stale for 15-30s, not propagating that 502 to 2 million players who will then all retry at once.
Finally, have a real troubleshooting runbook for when a shield PoP goes dark mid-event. We've had a primary shield PoP lose power 12 minutes into a 3M-viewer concert. If your failover is DNS-based with a 60s TTL, you're dead — half your mid-tiers will keep hammering a black hole. Use Anycast or a load-balancer health check that fails over in <5s, and have your mid-tiers configured with a shield backup list, not a single shield host. Keep proxy_next_upstream or equivalent enabled for 502/504/timeout, but cap retries to 1. And alert on the right signal: don't watch average hit ratio, watch origin RPS per rendition per segment and collapsed request queue depth at the shield. If origin RPS for 1080p suddenly jumps from 0.25 rps (one per 4s) to 40 rps, your collapsing broke or your cache key diverged. That's your 90-second warning before origin OOMs.
Frequently Asked Questions
How many shield layers do I actually need for 1-3 million concurrent viewers?
For under 750k concurrent on 6s segments, a single well-collapsed Origin Shield (1-2 PoPs) plus edge is usually enough. For 1M-3M, we mandate two tiers: ~12-20 regional mid-tiers fanning into 2 origin shield PoPs. Beyond 3M or if you're doing low-latency CMAF with 200-500ms chunks, add a third aggregation tier or you'll see mid-tier egress saturate before your origin does. The key metric isn't viewer count, it's unique segment requests per second — at 2M viewers and 4s segments, you're absorbing ~500k req/s at the edge, but only ~1.5 req/s per rendition should reach origin.
Our hit ratio is 97% but origin is still overloaded — what are we missing?
Almost always it's cache key fragmentation or collapsed forwarding not actually queuing. Check for un-stripped query strings, Authorization headers varying per user, or Vary: * leaking into cache keys. A single cache-buster like ?ts=16848234 makes every request a miss. Second, verify your collapsing is global per PoP, not per server. If your shield PoP has 16 cache servers without consistent hashing, you get 16 parallel origin fetches for the same segment. You want 1 forward per PoP per segment, not 1 per server.
Can we serve low-latency CMAF and standard LL-HLS/DASH from the same origin without doubling load?
Yes, if you package once and cache smart. Generate CMAF chunks once and have both manifests reference the same chunk files. Don't create separate /ll-dash/ and /ll-hls/ paths to the same bytes — that doubles your cache footprint and origin fetches. Normalize to a single canonical path and use different manifests. Use chunk-level TTLs of 300-500ms and enable chunk prefetch at the shield so the next CMAF chunk is already warming while the current one is being served.
How do you prevent DVR rewinds from causing a second thundering herd?
Isolate live-edge cache from DVR cache. Give DVR segments (older than 60s) a much longer TTL — 300s to 900s — and allow them to be cached at regional mid-tiers for hours, not seconds. Enable byte-range caching if your DVR supports trick-play. Most importantly, don't prefetch DVR — it's random access. But do set stale-while-revalidate=60s and stale-if-error=120s on DVR manifests and segments so a surge of rewinds for a goal replay serves stale instantly while the mid-tier revalidates asynchronously.