DEV Community

BlackNeuron
BlackNeuron

Posted on

Your CDN says it is protecting you. I checked, and 0% of the traffic was cached.

You put your site behind a CDN, watched the dashboard turn green, and moved on. The edge will soak up the floods now. Right?

Maybe. There is a quiet failure mode that makes the whole thing a placebo, and the dashboard will happily stay green while it happens: your responses are not actually being cached.

The problem in one sentence

A CDN only shields your origin from a flood to the extent that it answers requests from its own cache. Every response the edge cannot cache is a response your origin has to generate itself. So under a flood of uncacheable requests, the edge forwards all of them, and your origin takes the full load as if the CDN were not there.

The math is blunt:

How much of a 50,000 rps flood reaches your origin, by edge cache hit-ratio

At a 0% hit-ratio your origin sees the entire flood. At 95% it sees a twentieth of it. A CDN does not move you along that curve by being installed. It moves you by actually caching, and that is a property of your response headers, not the vendor's marketing.

Why the edge quietly caches nothing

None of these require a misconfigured CDN. They are ordinary application defaults:

  • Cache-Control: private or no-store. Frameworks slap these on by default for anything that looks dynamic. Perfectly correct for a logged-in page. Also means the edge forwards every hit to origin.
  • A Set-Cookie on the response. A session cookie on your HTML (or worse, on your static assets) tells shared caches not to store it. One analytics or CSRF cookie can make your whole site uncacheable.
  • max-age=0 / no-cache. The object revalidates with origin on every request, so the origin is in the loop every time anyway.
  • cf-cache-status: DYNAMIC. Cloudflare's default for anything without an explicit cache rule is to not cache it at all. Most sites never add the rule.

Any one of these, sitting on the paths a flood targets, and your expensive edge is a very fast reverse proxy to the thing you were trying to protect.

So I wrote a small tool to measure it

I wanted a one-command answer to "what fraction of my own traffic does the edge actually absorb, right now?" So I built edge-cache-check in Rust. It is deliberately boring on the network: it fetches your own pages and reads the cache headers. No flooding, no attacks, single binary, no config.

The flow:

  1. Fetch the landing page and fingerprint the edge from its headers (cf-cache-status, x-cache, x-vercel-cache, via, server).
  2. Read the HTML and build a representative sample of your own URLs: the static assets it references plus a few internal pages.
  3. Request each URL twice. The first request warms the edge; the second is the one scored.
  4. Classify each warmed response as EDGE (served from cache), CACHE (cacheable but missed), or ORIGIN (cannot be cached), with the reason.
  5. Report an absorption score for the static assets, whether the landing page is cached, and the top reasons anything falls through.

The interesting part: deciding "did the origin see this?"

Every CDN spells "cache hit" differently, so the load-bearing logic is normalising them into one honest answer. A hit is a hit whether it is Cloudflare's cf-cache-status: HIT, CloudFront's x-cache: Hit from cloudfront, Vercel's x-vercel-cache: HIT, or simply an Age header greater than zero (if the object has been sitting in cache for 12 seconds, it came from cache):

let hit = h.cf_cache.as_deref()
        .map(|s| matches!(s.to_uppercase().as_str(),
             "HIT" | "REVALIDATED" | "UPDATING" | "STALE"))
        .unwrap_or(false)
    || h.x_cache.as_deref().map(|s| s.to_lowercase().contains("hit")).unwrap_or(false)
    || h.x_vercel.as_deref().map(|s| { let u = s.to_uppercase(); u == "HIT" || u == "STALE" }).unwrap_or(false)
    || h.age.map(|a| a > 0).unwrap_or(false);

if hit {
    return (Class::Absorbed, "served from edge cache".into());
}
Enter fullscreen mode Exit fullscreen mode

If it was not a hit, the useful output is not "MISS", it is why. That is what turns the result into a fix instead of a shrug:

if !has_vendor {
    return (Class::Origin, "no CDN edge detected - request reaches origin".into());
}
if cc.contains("no-store")  { return (Class::Origin, "Cache-Control: no-store".into()); }
if cc.contains("private")   { return (Class::Origin, "Cache-Control: private".into()); }
if cc.contains("no-cache")  { return (Class::Origin, "Cache-Control: no-cache (revalidate every time)".into()); }
if h.set_cookie && !cc.contains("public") {
    return (Class::Origin, "Set-Cookie on the response defeats shared caching".into());
}
Enter fullscreen mode Exit fullscreen mode

The two-fetch design matters here too. A single request to a cold object on a large multi-PoP edge is a MISS even when the object is perfectly cacheable, so a naive checker would cry wolf. Warming first, then scoring the second response, separates "cannot be cached" from "just was not cached yet." The tool reports those as two different buckets on purpose.

Running it

cargo run --release -- example.com
# or build once, then:
./target/release/edge-cache-check example.com
Enter fullscreen mode Exit fullscreen mode

A healthy run is calm: your assets come back [EDGE ], the score is high, and the verdict is STRONG. An unhealthy one is specific about the damage:

    static assets:   2/14 served from edge (14%), 21% cacheable in total
    landing page:    NOT cached - every hit reaches origin
    why not cached:
      11x  Set-Cookie on the response defeats shared caching
       3x  Cache-Control: private

[!] WEAK: an edge is present but is absorbing very little.
Enter fullscreen mode Exit fullscreen mode

That is a two-line fix (drop the cookie from static asset responses, add a cache rule), and a real change in how much of a flood your origin ever sees.

The remediation

  • Make static assets genuinely cacheable: a long max-age, immutable, and no Set-Cookie on asset responses.
  • Add an explicit cache/edge rule on the paths that can tolerate it, rather than trusting the CDN's default (which, on Cloudflare, is to cache nothing).
  • For the dynamic paths that legitimately cannot be cached, stop relying on the origin as the only line of defence. That is what edge rate-limiting and a WAF are for.

Absorption is not the whole of DDoS resilience, but it is the pillar people assume they have for free the moment they install a CDN, and most do not.

One rule

Run it only against domains and infrastructure you own or are explicitly authorized to test. It only reads your own public responses, but point it at your own stuff.

Code

If you run it on your own site and the number surprises you, the reason column is usually the interesting part. I would like to hear what caught you out.

Written while building defensive tooling at BlackNeuron.

Top comments (0)