JSTGTECH
← Back to blog

CloudFront invalidations without nuking your AWS bill

5 min read

If your deploy pipeline ends every push with `aws cloudfront create-invalidation –paths “/*”`, it works, but it’s the CDN equivalent of clearing your entire browser cache because one tab was stale. It’s slow to propagate, it costs money past a fairly small free tier, and — the part people miss — it doesn’t actually need to exist for most of your assets if you name your files right. This site’s own deploy (deploy.yml) does a scoped invalidation of exactly two paths, not a wildcard, and here’s the reasoning and the mechanics behind that choice.

How invalidation pricing actually works

CloudFront gives you **1,000 free invalidation path requests per month, per account**. Past that, each additional path costs $0.005. The part that trips people up is what counts as “one path”:

  • /* — a single wildcard path — counts as one path, no matter how many objects it matches at the edge. Nuking your entire distribution with one wildcard call costs the same as invalidating one specific file.
  • An explicit list of paths in a single create-invalidation call is billed per path in the list. aws cloudfront create-invalidation --paths "/index.html" "/blog/index.html" "/rss.xml" bills three paths, not one call.

So a single /* is actually the cheapest way to invalidate by path-count — the trap isn’t cost from the wildcard itself, it’s what a wildcard does operationally: it forces CloudFront to revalidate every object in the distribution against the origin on the next request, which means a burst of origin requests to S3 right after every deploy, and it invalidates objects that never changed. On a low-traffic personal site that’s harmless. On anything with real traffic or a slow/rate-limited origin, a full-distribution wildcard after every deploy is the thing that actually costs you — in origin load and in cache-miss latency for visitors who hit the edge in the seconds after the invalidation lands, not in the $0.005-per-path line item.

The free tier resets monthly and is shared across all distributions in the account, so if you run several sites or a multi-tenant setup off one AWS account, a chatty pipeline on one distribution eats the free allowance for all of them.

The real fix: stop invalidating, start fingerprinting

Invalidation is a workaround for a caching mistake: telling CloudFront to cache a URL for a long time when the content behind that URL can change. Fingerprinted (content-hashed) filenames remove the mistake instead of compensating for it. Astro’s build already does this for you — run npm run build and look at dist/_astro/:

_astro/client.a1b2c3d4.js
_astro/index.e5f6a7b8.css

The hash is derived from the file’s content. Change one character of source, the hash changes, the URL changes. Because the URL is different, there’s nothing to invalidate — the old URL still exists at the edge serving old content (fine, nothing references it anymore), and the new URL is a cache miss exactly once, everywhere, the first time each edge location requests it. This is why you can safely set:

Cache-Control: public, max-age=31536000, immutable

on everything under _astro/ (or /assets/, however your bundler names it). A year-long max-age plus immutable tells both browsers and CloudFront “never revalidate this, ever” — and that’s true, because the filename itself guarantees the content can’t change out from under that URL. This is the single biggest lever for cutting invalidation traffic to near zero: the majority of a static site’s bytes (JS, CSS, hashed images) never need an invalidation call in their entire lifetime.

Set this at the S3 origin via object metadata (Astro/most bundlers don’t set Cache-Control on upload themselves — your sync step has to), or override it at the CloudFront cache behavior level with a policy scoped to the hashed asset path pattern:

aws s3 sync ./dist s3://my-bucket/ \
  --exclude "*" --include "_astro/*" \
  --cache-control "public, max-age=31536000, immutable" \
  --metadata-directive REPLACE

Run this pass before the pass that uploads everything else, so the hashed-assets rule doesn’t get clobbered by a broader default Cache-Control applied later in the sync.

Where you still need surgical invalidation

Fingerprinting only works for files whose name changes when their content changes. Two categories of file don’t get that treatment, and those are the only things worth invalidating:

  • index.html and any other non-hashed HTML entrypoint. The URL /blog/index.html (or /blog/ via the pretty-URL rewrite) has to stay stable — it’s what’s in every bookmark, backlink, and search index entry — but its content changes every time you publish. Give these a short max-age (or no-cache so CloudFront/browsers always revalidate against origin) and invalidate them explicitly on deploy.
  • Non-hashed static assets you can’t rename, like favicon.ico, robots.txt, sitemap-index.xml, or rss.xml — anything a spec or a client expects at a fixed path.

For this site, that means the deploy step invalidates a short, explicit list, not a wildcard:

aws cloudfront create-invalidation \
  --distribution-id "$CF_DISTRIBUTION_ID" \
  --paths "/index.html" "/blog/*" "/rss.xml" "/sitemap-index.xml"

/blog/* is doing real work here — every post and tag page under /blog/ is HTML with a stable, non-hashed URL, so a scoped wildcard on just that subtree is the right call. It’s still one billed path (wildcards always are), but more importantly it only forces revalidation on the part of the site that actually changes on every deploy, leaving the immutable hashed assets alone.

The gotcha: invalidations aren’t instant, and they queue

create-invalidation returns immediately with a status of InProgress — the CLI call succeeding does not mean the content is gone from every edge location yet. Full propagation across all of CloudFront’s edge locations typically completes within a few minutes, but there’s no SLA guaranteeing a specific time, and it’s not uncommon to see stale content served from one edge location after another has already updated. If your deploy pipeline runs a post-deploy smoke test that curls the live URL immediately after create-invalidation returns, don’t assert on content freshness right away — poll get-invalidation for Status: Completed first, or just accept some propagation lag in the check:

aws cloudfront get-invalidation \
  --distribution-id "$CF_DISTRIBUTION_ID" \
  --id "$INVALIDATION_ID" \
  --query 'Invalidation.Status'

The second, less obvious gotcha: **invalidation requests queue per distribution**, and each request can list at most 3,000 paths (or 15 with wildcards). If a script fires invalidations in a tight loop — say, a bulk content migration that invalidates per-file instead of batching — later requests sit InProgress behind earlier ones rather than running in parallel, so a burst of small invalidations can take noticeably longer to fully clear than one batched call with the same total path count. Batch your paths into as few create-invalidation calls as the file-list limits allow, rather than looping a call per file.

What to actually do

Fingerprint everything your bundler can fingerprint and set max-age=31536000, immutable on it — that’s most of your bytes and it needs zero invalidation calls, ever, for the life of the file. For the small set of non-hashed entrypoints (HTML, robots.txt, feeds), use a short max-age and a scoped explicit-path invalidation on deploy, not /*. You’ll stay comfortably inside the free 1,000-path monthly allowance even with several deploys a day, and — the bigger win — your origin only gets hit for the handful of objects that actually changed, not your entire distribution.

Related posts