Flash Crowd Caching: Surviving Viral Traffic Without Origin Collapse

I've seen an origin die in under eight seconds. It was a Friday afternoon in 2021, a niche SaaS client got retweeted by a major tech account, and their traffic went from 400 requests per minute to 48,000. Their CDN was "configured" but not engineered for a flash crowd, and the Postgres primary behind the origin fell over before anyone could even scale it. That’s the reality of viral traffic — it doesn’t ramp, it detonates.

Why a Naive Cache Config Fails in the First 30 Seconds

Most teams think caching is about setting a TTL and walking away. Under normal load, that works fine. Your edge hit ratio sits at 92%, origin CPU is bored, everyone's happy. But a flash crowd isn't normal load — it's thousands of concurrent clients asking for the exact same uncached or just-expired resource at the same time. If that resource has a one-minute TTL and it expires during the spike, you don't get one origin request. You get a thundering herd of ten thousand requests slamming your origin simultaneously, a classic cache stampede. I've debugged this exact pattern more times than I can count, and it always looks the same in the logs: a perfect cliff where hit ratio drops to near zero.

The core problem is that traditional TTLs create synchronized expiry. When 50 edge nodes all cache /api/trending at 12:00:00 with a 60s max-age, they’ll all revalidate at 12:01:00. Add a 100x traffic multiplier and you've engineered your own DDoS. We learned this the hard way on a media site covering live election results — every edge expired at the top of the minute and the origin got hammered even though the data hadn't changed. The fix isn't a longer TTL, it's decoupling freshness from expiry. You need a strategy where stale data can be served while fresh data is fetched asynchronously, which is exactly what the HTTP specifications for stale-while-revalidate were designed for. For the low-level semantics, the HTTP Working Group Specifications are the definitive reference.

A related trap is thinking your origin can auto-scale its way out. It can't. Even with Kubernetes HPA, container startup takes 20-45 seconds, and database connections don't scale at all. By the time your new pods are ready, the origin has already collapsed under connection queueing. The only system that can absorb a 100x spike in milliseconds is the edge cache itself. That's why Probabilistic Caching with Bloom Filters: Memory-Efficient Cache Lookups matters at scale — you need to know what you don't have cached without paying a lookup penalty on every request during the surge.

# Varnish / VCL example: protect origin with grace + coalescing
# Don't let 10k MISSes become 10k fetches

sub vcl_hit {
    if (obj.ttl >= 0s) {
        return (deliver);
    }
    # Hit-for-pass or grace: serve stale while background fetch happens
    if (obj.ttl + obj.grace > 0s) {
        return (deliver);
    }
    return (fetch);
}

sub vcl_backend_response {
    # Keep stale object for 1 hour to serve during flash crowd
    set beresp.grace = 1h;
    # Allow stale-while-revalidate for 60s per RFC 5861
    set beresp.http.Cache-Control = "public, max-age=10, stale-while-revalidate=60, stale-if-error=300";
    
    # Collapse simultaneous misses into one origin request
    set beresp.ttl = 10s;
}

# In vcl_recv - request coalescing (requires VMOD or native waiting list)
sub vcl_recv {
    # Enable waiting list for cache misses
    set req.hash_always_miss = false;
}

Request Coalescing and Stale Serving: The Two Mechanisms That Save You

If you implement only two things before your next viral moment, make it these: request coalescing (also called request collapsing or origin shielding) and stale-while-revalidate. They solve different halves of the same problem. Coalescing ensures that when 5,000 requests arrive for a cache MISS at the same edge PoP, only one request goes to origin or shield and the other 4,999 wait for that single response. Without it, your shielding layer is useless. I once saw a client with shield enabled but coalescing disabled — their shield PoP got 8,000 RPS to origin because every edge independently missed.

Stale-while-revalidate is your grace period. Defined in RFC 5861 and now widely supported in browsers and CDNs, it lets you say: "this response is fresh for 10 seconds, but you may serve the stale version for another 60 seconds while you revalidate in the background." During a flash crowd, that tiny directive is the difference between survival and collapse. The edge keeps serving instant stale hits while one background fetch updates the cache. Users get a 15ms response that’s one second old instead of a 2-second wait that times out. The web.dev Articles library has excellent real-world measurements on how this impacts Core Web Vitals under load, and it’s directly applicable here.

We pair these with a single shield PoP and a tiered cache topology. Instead of 150 edges hammering origin, they all hit a shield, and the shield coalesces again. So a global spike of a million requests becomes one origin request. It sounds simple, but the configuration is fiddly and you have to test it. Don't trust the dashboard — run a load test that actually expires the cache mid-test. And if you're trying to pre-warm caches before a known event like a product launch, take a look at Machine Learning for Cache Warming: Predictive Prefetching That Actually Works — we've had solid results predicting which assets will be hot. You'll also want to think about Cache Coherence Protocols at the CDN Edge: Keeping Distributed Caches in Sync when that warmed content needs to be invalidated consistently.

// Cloudflare Workers / Fastly Compute@Edge: coalescing + SWR in code
// Edge logic to collapse and serve stale

async function fetchWithCoalescing(request, cache) {
  let response = await cache.match(request);
  
  // Serve stale immediately, revalidate in background (SWR)
  if (response) {
    const age = Date.now() - Date.parse(response.headers.get('date'));
    const maxAge = 10 * 1000; // 10s fresh
    const swr = 60 * 1000;    // 60s stale-while-revalidate
    
    if (age < maxAge + swr) {
      // Non-blocking revalidation
      if (age > maxAge) {
        event.waitUntil(revalidateAndUpdateCache(request, cache));
      }
      // Add header so you can see it in logs
      response.headers.set('X-Cache-Status', age > maxAge ? 'STALE' : 'HIT');
      return response;
    }
  }

  // Single-flight lock for MISS - use Cache API lock or KV mutex
  // This prevents thundering herd on initial miss
  return await singleFlight(request.url, () => fetchAndCache(request, cache));
}

async function revalidateAndUpdateCache(request, cache) {
  const fresh = await fetch(request, { cf: { cacheTtl: 10 } });
  if (fresh.ok) {
    fresh.headers.set('Cache-Control', 'public, max-age=10, stale-while-revalidate=60');
    await cache.put(request, fresh.clone());
  }
}

What We Actually Monitor When Traffic Goes Vertical

Dashboards lie during a flash crowd if you're looking at averages. A 30-second average hit ratio hides the five-second window where you collapsed. We alert on 5-second p95 origin request rate and edge coalescing queue depth, not just overall hit ratio. On one e-commerce flash sale, our 1-minute hit ratio showed 93% the whole time, but the 5-second view revealed three separate 2-second drops to 61% — each one corresponded to a burst of 502s from origin because the DB connection pool saturated.

You need to know your numbers before the spike hits. We baseline every property and set explicit thresholds for what triggers serve-stale mode. For a typical API that serves JSON with a 10s TTL, here's what we've come to treat as healthy versus critical when traffic jumps 50-100x. These aren't theoretical — they're pulled from a Fastly + Varnish setup handling about 2M RPM during peak viral events last year.

Metric (5s window) Normal Baseline During 80x Flash Crowd Collapse Threshold
Edge Cache Hit Ratio 94% - 97% 98.5% - 99.4% < 88% (origin will saturate)
Origin Requests / sec 45 - 80 RPS 8 - 15 RPS (coalesced) > 500 RPS sustained for 10s
Edge TTFB p95 35ms 42ms (stale) / 18ms (hit) > 800ms (queueing)
Coalescing Wait Queue Depth 0 - 2 1,200 - 4,500 waiting / collapsed to 1 fetch > 10,000 with timeouts
Stale-While-Revalidate Serves < 1% of hits 22% - 35% of hits > 50% (TTL too short)

Putting It Together: A Production Config That Doesn't Fold at 20k RPS

Theory's nice, but flash crowds don't care about your architecture diagram. What matters is the three lines in your VCL or Nginx config that decide whether 18,000 requests for the same viral video page hit your origin or get coalesced into one. We've seen customers go from a 12% origin hit ratio to 98.7% with just two changes: turning on proper request collapsing and serving stale for at least 60 seconds.

Don't try to be clever with TTLs here. Your hot object might have a 60s TTL for freshness, but you'll want a grace window 10-20x that. For a typical media or product page spike, we run something like 60s TTL + 24h grace on the edge, plus stale-while-revalidate=120 and stale-if-error=86400. That way when your origin latency spikes from 80ms to 4 seconds — and it will — you're not queuing 15,000 connections behind a single cache MISS. The edge keeps serving the last good version for two minutes while only one thread goes to revalidate. Your users see a 45ms HIT instead of a 5-second timeout.

# Varnish VCL - Flash Crowd Hardened
sub vcl_hit {
    if (obj.ttl >= 0s) {
        return (deliver);
    }
    # Serve stale while revalidating in background
    if (obj.ttl + obj.grace > 0s) {
        if (std.healthy(req.backend_hint)) {
            # One request goes to origin, rest get stale
            return (deliver);
        } else {
            return (deliver);
        }
    }
    return (fetch);
}

sub vcl_backend_response {
    set beresp.ttl = 60s;
    set beresp.grace = 24h;
    set beresp.keep = 7d;
    # Allow 120s stale-while-revalidate
    set beresp.http.Cache-Control = "public, max-age=60, stale-while-revalidate=120, stale-if-error=86400";
}

# Fastly / CDN equivalent - enable request coalescing
# set req.hash_always_miss = false; // ensure collapsing is ON
# In Fastly UI: Enable "Shielding" + set "stale_while_revalidate" to 120s

Beyond the Basics: Shielding, Key Fragmentation, and Silent Failures

Once you've got coalescing and stale serving nailed, the next failure mode isn't your edge — it's your shield. Without tiered caching, 200 POPs all missing at once still means 200 origin requests in the same second. That's enough to tip a fragile Node.js or PHP backend. We always pair flash crowd protection with a single shield POP (or ideally, origin shield + mid-tier). That collapses those 200 requests into 1 origin fetch. If you're running a multi-region origin, make sure your shielding topology matches your Consistent Hashing for CDN Cache Distribution: A Practical Implementation Guide — otherwise a single shield failover can re-hash 30% of your hot keys to cold nodes.

The sneakier killer is cache key fragmentation. We audited a site that thought they were coalescing beautifully during a Super Bowl ad spike, but their hit ratio stayed at 42%. Why? Their cache key included a _fbp cookie, a UTM param, and a random ab_test_id. Every user had a unique key, so nothing coalesced. Normalize your keys aggressively before you need to. Strip all query params except the 2-3 you actually vary on, ignore cookies for anonymous traffic, and sort what's left. If you must vary on personalization, do it via Edge Side Includes or client-side fetches, not by busting the cache for everyone.

Finally, watch your purge strategy during the spike itself. This is the worst time to run a purge all because your PM saw a typo in the headline. A full purge drops you right back to zero and recreates the thundering herd you just solved. Instead, use granular invalidation. A well-designed Cache Tag Design Patterns: Organizing Content for Granular Invalidation setup and Surrogate Keys for Targeted Cache Purging: Beyond URL-Based Invalidation let you update just surrogate-key: article-88291 in under 150ms globally, without invalidating the other 50,000 objects that are absorbing the viral traffic. We've seen purge-all during a flash crowd turn a 99.9% hit ratio into 15% for 6 minutes — that's an origin collapse you caused yourself.

Frequently Asked Questions

How long should stale-while-revalidate be during a flash crowd?

Longer than you think. For viral, non-transactional content, 60-120 seconds is our default. For API or pricing data where staleness matters, keep it tight at 5-10 seconds but extend stale-if-error to at least 1 hour. You'd rather serve a price that's 10 seconds old than a 503. Measure your origin P95 revalidation time and set stale-while-revalidate to 2x that value — if revalidating takes 800ms under load, a 2s window won't be enough.

Doesn't request coalescing hurt TTFB for users who get queued?

Yes, but barely, and it's a trade worth making. The coalesced requests wait maybe 80-300ms for the single origin fetch to return, versus 3-8 seconds if that origin gets overwhelmed and starts queueing or dropping connections. In our tests at ~25k RPS on a single URL, P99 TTFB with coalescing enabled was 210ms. Without it, P99 shot past 4,200ms and we saw 18% 502s. Use backend request timeouts of 3-5s so a hung origin doesn't hold the collapsed queue forever.

Should I increase my TTL to hours when I know traffic is coming?

You can, but don't rely on it. Bumping TTL to 1 hour helps if you know 24 hours in advance, but most flash crowds give you 30-90 seconds of warning. Grace and stale-while-revalidate are your safety nets for the traffic you didn't predict. A better pattern is: keep your logical TTL short (60s) for agility, but give yourself massive grace. That way you get both fast purges and flash crowd resilience.

My content is personalized per user — can I still use this?

Partially. You'll need to split the page. Cache the shell — the viral image, article body, layout — as a single public object with aggressive coalescing, then fetch the personalized bits (username, cart, recommendations) via a separate private XHR/fetch that bypasses cache or uses a short, keyed cache. Don't make the entire HTML page Vary: Cookie or you've just disabled the one mechanism that saves you.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker