"You can't cache our site, it's dynamic." I've heard that from three different teams, and it was wrong every time. A page being personalized doesn't mean every byte of it changes per request. The art of caching a dynamic site with CloudFront is figuring out exactly which parts vary, and on what, then caching everything else aggressively.

This post is the mental model and the specific CloudFront knobs I use to take a "dynamic" app from a 5% cache hit rate to north of 80%.

Separate the cache key from the cache policy

CloudFront decides what counts as the "same" request using the cache key. By default the key is just the host and path, which means two users requesting /dashboard get the same cached object, leaking one user's data to another. The fix isn't to disable caching; it's to add the right things to the cache key.

Cache policies control the key (and the TTLs). Origin request policies control what gets forwarded to your origin without affecting the key. Keeping these straight is the whole game:

  • A header that changes the response and should split the cache → add it to the cache policy.
  • A header your origin needs but that doesn't change the response → add it to the origin request policy only.

Cache static and semi-static paths hard

Most "dynamic" sites are 80% cacheable by path. Versioned assets under /static/* get a one-year TTL because their filenames are content-hashed. API responses that change slowly, a product catalog, a config endpoint, get a short TTL and a stale-while-revalidate behavior so users never wait on a refresh.

resource "aws_cloudfront_cache_policy" "static_assets" {
  name        = "static-assets-1y"
  default_ttl = 86400
  max_ttl     = 31536000
  min_ttl     = 86400

  parameters_in_cache_key_and_forwarded_to_origin {
    cookies_config    { cookie_behavior = "none" }
    headers_config    { header_behavior = "none" }
    query_strings_config { query_string_behavior = "none" }
    enable_accept_encoding_brotli = true
    enable_accept_encoding_gzip   = true
  }
}

Cache the personalized parts on a vary key

For a page that differs by logged-in user or locale, you still cache, you just vary the key on the dimension that matters. Vary on a x-currency header or an Accept-Language value and you get one cached object per currency or language, not per user. The trap is varying on something high-cardinality like a session cookie, which shatters the cache into millions of single-use entries.

The cache hit rate is roughly inversely proportional to the cardinality of your cache key. Every dimension you add to the key divides your hit rate. Add only what genuinely changes the bytes.

Use CloudFront Functions and the right headers

For true per-request logic that can't be cached, auth checks, A/B bucketing, header normalization, push it to the edge with a CloudFront Function rather than round-tripping to the origin. Normalizing the Accept-Language header to a handful of supported locales before it hits the cache key is a classic use:

function handler(event) {
  var req = event.request;
  var lang = req.headers['accept-language'];
  var normalized = 'en';
  if (lang && lang.value.startsWith('fr')) normalized = 'fr';
  req.headers['x-locale'] = { value: normalized };
  return req;
}

Then your cache policy keys on x-locale (two values) instead of the raw Accept-Language (hundreds). Pair this with origin Cache-Control headers: send max-age for shared caching plus private on genuinely per-user fragments so they bypass the shared cache cleanly.

Invalidate by versioning, not by API call

Invalidations cost money past the first 1,000 paths per month and are slow to propagate. For deploys, I version asset paths (content hashing) so new content gets a new URL and old caches simply expire, no invalidation needed. I reserve actual create-invalidation calls for emergency content pulls.

Takeaways

  • "Dynamic" rarely means uncacheable, most sites are 80% cacheable once you split paths by how they vary.
  • Keep cache policies (which set the key) distinct from origin request policies (which forward without keying).
  • Vary the cache key only on low-cardinality dimensions; session cookies in the key destroy your hit rate.
  • Version asset URLs so caches expire naturally, and reserve invalidations for emergencies.