AWS Aug 3, 2026 · 6 min read

CloudFront in front of your app: why your hit rate is 30%

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

Putting CloudFront in front of an app is one of those decisions everyone applauds in the meeting and almost nobody verifies afterward. The distribution gets created, the domain points at it, the first test shows better latency, and everyone assumes it's "cached now". Months later you open the metrics and the hit rate is 30%: seven out of ten requests still reach your origin, you're paying transfer twice, and you now have an extra layer to debug through when something looks odd. It's not that the CDN doesn't work. It's that caching isn't a checkbox: it's three decisions —what identifies a response, how long it lives, and how it gets replaced— and those three are yours, not AWS's.

The cache key is 90% of the problem

A CDN stores responses indexed by a key. If two requests produce the same key, the second one is a hit. Everything you put into that key multiplies the number of possible entries and divides your hit rate.

The case I've run into most: someone forwards the session cookie to the origin "because the app needs it" and accidentally puts it in the cache key too. From that moment on, every user gets their own copy of every resource. Technically there's a cache; in practice it caches nothing. Same story with forwarding all query strings: a campaign's utm_source values turn one URL into fifty.

The piece that fixes this is realizing CloudFront has two separate policies: the cache policy defines what goes into the key, and the origin request policy defines what gets sent to the origin. They are not the same thing. The cookie can travel to your origin without fragmenting the cache.

resource "aws_cloudfront_cache_policy" "static" {
  name        = "static-assets"
  min_ttl     = 0
  default_ttl = 86400
  max_ttl     = 31536000

  parameters_in_cache_key_and_forwarded_to_origin {
    enable_accept_encoding_brotli = true
    enable_accept_encoding_gzip   = true

    cookies_config { cookie_behavior = "none" }
    headers_config { header_behavior = "none" }

    query_strings_config {
      query_string_behavior = "whitelist"
      query_strings { items = ["v"] }
    }
  }
}

The rule I apply without exceptions: the cache key starts empty and you add whatever you can justify. Never the other way around.

Cache-Control decides; the distribution only sets limits

The distribution's TTLs confuse a lot of people because they look like the main configuration, and they aren't. default_ttl only applies when the origin sends no cache headers. max_ttl is a ceiling. min_ttl is a floor. The real decision-maker is your origin, route by route.

And that's where the header with the best performance-per-character I know lives: s-maxage.

# Bundle with a content hash in its name: genuinely immutable
Cache-Control: public, max-age=31536000, immutable

# HTML for a page that changes: the browser doesn't keep it, the CDN does
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400

max-age talks to the browser; s-maxage talks to shared caches —the CDN— and takes precedence over it. That separation is what lets you ship changes fast: the user's browser holds nothing, the edge holds five minutes, and purging the edge is under your control. Purging someone's browser never is.

💡 If your HTML leaves the origin with max-age=3600, you don't have a CDN problem: you have users stuck on an old version for an hour with no way to fix it. max-age=0, s-maxage=3600 caches just as well and can actually be rolled back.

Invalidation is plan B; versioning is plan A

Invalidations are the tool everyone reaches for first and the one you should need least. They're asynchronous, they take time, the first 1,000 paths a month are free and then billed, and above all: a /* on every deploy empties the whole cache and sends all your traffic to the origin at once, right at the moment you just deployed. That's the worst possible combination.

The alternative is making invalidation unnecessary. If your assets carry a content hash in the filename, a deploy doesn't modify files: it publishes new files, at new URLs, that nobody has cached. There's nothing to invalidate. The only thing that moves is the entry document, and that one already has a short s-maxage.

When it's still needed, I scope it:

# Plan B, and scoped: entry documents only
aws cloudfront create-invalidation \
  --distribution-id E2XXXXXXXXXXXX \
  --paths '/index.html' '/blog/*'

stale-while-revalidate: let the origin stop suffering

Without stale-while-revalidate, TTL expiry is a cliff: the request that arrives right after it waits for the origin to respond in full. Under real traffic, several requests arrive at once and all of them hit the origin for the same URL.

With SWR, the edge serves the stale copy immediately and revalidates in the background. The user never pays the refresh latency, and your p99 stops showing periodic spikes that correlate with nothing. Add stale-if-error and you get a degraded mode for free: if the origin returns a 5xx, the last good copy keeps being served instead of propagating the error.

If the origin still feels the revalidation traffic, Origin Shield adds an intermediate cache layer that consolidates those requests. It costs extra: I turn it on when the data asks for it, not by default.

How I measure before touching anything

The console's CacheHitRate tells you that you have a problem, not where it is. That's what the logs are for: every line carries x-edge-result-type with Hit, RefreshHit, Miss, Error. Grouping by URI and by that field gives you the diagnosis in one query.

SELECT uri, x_edge_result_type, count(*) AS n
FROM cloudfront_logs
WHERE date >= current_date - interval '7' day
GROUP BY 1, 2
ORDER BY n DESC
LIMIT 50;

If the same URI shows up near the top with Miss and high volume, there are only two explanations: the cache key is fragmenting it, or the origin is sending a Cache-Control that prevents caching. Both take ten minutes to fix once you know which one it is.

What I'd do today, in this order

Decide Cache-Control at the origin per route type before anything else. Start the cache key empty and add only what's justifiable. Put content hashes in asset filenames so invalidation becomes exceptional. stale-while-revalidate on anything that's HTML. And measure with x-edge-result-type before pulling the next lever.

A badly configured CDN isn't neutral: it adds a hop, a layer for bugs to hide in, and a transfer bill you pay twice. Configured well, it's one of the few things in AWS where savings and latency improve in the same direction. The difference between those two versions isn't the product: it's three decisions that fit in an afternoon.

Yohangel Ramos

Written by Yohangel Ramos

Senior Fullstack Developer and Tech Lead. I build with React, Next.js, Nest.js and AWS — and I write about what I learn along the way.

Let's talk →

Keep reading

Frontend

INP: why your app feels slow even when it loads fast

IA

The startup stack in July 2026: what I would use today to build a SaaS