CloudFront Cost Optimization: Cache Keys to Price Classes

CloudFront cost optimization with current prices: cache hit ratio, cache keys, TTLs, Origin Shield, compression, price classes and the Savings Bundle.

Here’s a detail that surprises people the first time they model it: on the CloudFront line of your bill, a cache hit and a cache miss cost the same. CloudFront charges for the bytes it sends to viewers and the requests it answers, and it doesn’t care whether the object came from the edge or from your origin a moment earlier.

The miss costs you somewhere else. It’s an S3 GET, a trip through the load balancer, a container doing work it already did a minute ago, or egress from a server outside AWS. So CloudFront cost work is two jobs: make each byte and request at the edge cheaper, and stop the edge asking your origin for things it has already seen.

What CloudFront actually charges for

CloudFront prices by where the viewer is, not by AWS Region. These are pay-as-you-go list prices for viewers in the United States, Mexico and Canada at the time of writing:

Charge Price
Data transfer out to the internet First 1 TB a month free, then $0.085/GB for the next 9 TB, $0.080 for the next 40 TB, $0.060 for the next 100 TB, falling to $0.020/GB above 5 PB
HTTPS requests First 10 million a month free, then $0.0100 per 10,000
HTTP requests $0.0075 per 10,000 after the free 10 million
Data transfer from CloudFront to your origin (POST, PUT, uploads) $0.020/GB
Origin fetches from an AWS origin (S3, EC2, ELB, VPC origins) Free
Invalidations First 1,000 paths a month free, then $0.005 per path
Real-time logs $0.01 per million log lines
CloudFront Functions 2 million invocations a month free, then $0.10 per million
Lambda@Edge $0.60 per million requests plus $0.00005001 per GB-second, no free tier
Origin Shield (US) $0.0075 per 10,000 requests that reach it

Elsewhere it costs more: the next-9-TB rate is $0.110/GB for South America, $0.114 for Japan and Australia, and $0.120 for much of Southeast and East Asia. HTTPS requests from South America cost $0.0220 per 10,000, more than double the US rate.

The free origin fetch is why CloudFront beats serving straight from S3 or an ALB; we covered general egress in NAT gateway and data transfer costs. An origin outside AWS gets no such exemption, so every miss also bills at your provider’s egress rate.

Find out where the money goes

Before touching any setting, split last month’s CloudFront spend by usage type. Usage types are prefixed with a location code (US, EU, AP, JP, IN, SA and so on), so you’ll see US-DataTransfer-Out-Bytes, EU-Requests-Tier2-HTTPS, Invalidations and Executions-CloudFrontFunctions as separate lines. Each Cost Explorer API call costs $0.01.

# Read-only. First day of last month to first day of this month.
# GNU date (Linux):
START=$(date -d "$(date +%Y-%m-01) -1 month" +%Y-%m-%d)
END=$(date +%Y-%m-01)
# BSD date (macOS) instead:
# START=$(date -v1d -v-1m +%Y-%m-%d)
# END=$(date -v1d +%Y-%m-%d)

aws ce get-cost-and-usage \
  --time-period Start=$START,End=$END \
  --granularity MONTHLY \
  --metrics UnblendedCost UsageQuantity \
  --filter '{"Dimensions":{"Key":"SERVICE","Values":["Amazon CloudFront"]}}' \
  --group-by Type=DIMENSION,Key=USAGE_TYPE \
  --query 'ResultsByTime[0].Groups[].[Keys[0],Metrics.UnblendedCost.Amount,Metrics.UsageQuantity.Amount]' \
  --output text | sort -k2 -rn | head -20

If request lines rival the byte lines, you serve lots of small objects and per-request fees matter more than per-GB tiers. Large SA- or AU- lines with no customers there point at price classes. An Invalidations line usually means a wasteful deploy pipeline. Lambda@Edge shows up as Lambda-Edge-Request and Lambda-Edge-GB-Second; if it’s missing here, check the AWS Lambda service.

Then take an inventory of how each distribution is set up:

# Read-only. A null CachePolicy means the behavior still uses legacy cache settings.
aws cloudfront list-distributions \
  --query 'DistributionList.Items[].{Id:Id,Domain:DomainName,PriceClass:PriceClass,CachePolicy:DefaultCacheBehavior.CachePolicyId,Compress:DefaultCacheBehavior.Compress}' \
  --output table

Cache hit ratio is the lever that works on both bills

A better hit ratio won’t shrink DataTransfer-Out-Bytes. It shrinks everything behind CloudFront: origin requests, origin compute, Origin Shield requests, and egress from non-AWS origins. On an S3 origin each miss is at least a GET at $0.0004 per 1,000. On an application origin it’s real CPU time.

Measure it first

The CacheHitRate metric is one of CloudFront’s additional metrics. It’s off by default and bills up to eight CloudWatch custom metrics per distribution at $0.30 each, so at most $2.40 a month. Worth it on your busiest distributions. This command changes billing, so run it deliberately:

# Changes billing: enables additional metrics on one distribution.
aws cloudfront create-monitoring-subscription \
  --distribution-id E123EXAMPLE \
  --monitoring-subscription 'RealtimeMetricsSubscriptionConfig={RealtimeMetricsSubscriptionStatus=Enabled}'

A week later, read it back. Metrics live in us-east-1 with the dimensions DistributionId and Region=Global:

# Read-only. Daily average hit rate for the last 7 days.
# GNU date (Linux):
START=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)
# BSD date (macOS) instead:
# START=$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ)
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
aws cloudwatch get-metric-statistics --region us-east-1 \
  --namespace AWS/CloudFront --metric-name CacheHitRate \
  --dimensions Name=DistributionId,Value=E123EXAMPLE Name=Region,Value=Global \
  --start-time $START --end-time $END --period 86400 --statistics Average \
  --query 'sort_by(Datapoints,&Timestamp)[].[Timestamp,Average]' --output text

The metric only counts cacheable requests, so POSTs and errors don’t drag it down. If you’d rather not pay for metrics, standard access logs carry the same answer in the x-edge-result-type field.

Trim the cache key

CloudFront keeps a separate copy for every distinct combination of cache key values, so each extra value splits traffic into more, colder copies. The usual offenders:

  1. Query strings. Marketing links add utm_source, gclid and fbclid. If the cache key includes all query strings, every campaign link becomes its own cache entry for an identical page. Include only the parameters your origin actually varies on. Case and order matter too: ?a=1&b=2 and ?b=2&a=1 are cached separately.
  2. Cookies. Forwarding all cookies to a static path means caching per session, which is close to not caching. Put *.css, *.js and images in their own behavior with no cookies in the key.
  3. Headers. User-Agent has huge variety. If you need device-specific responses, use the CloudFront-Is-Mobile-Viewer style headers instead.

The fix that keeps the application working is an origin request policy: values listed there reach your origin without becoming part of the key. For plain static assets, the managed CachingOptimized policy keeps the key down to the normalized Accept-Encoding header. Don’t use it on mixed content: its minimum TTL is one second, so it caches responses even when the origin says no-store or private.

List your custom policies and look for anything that puts everything in the key:

# Read-only.
aws cloudfront list-cache-policies --type custom \
  --query 'CachePolicyList.Items[].CachePolicy.{Name:CachePolicyConfig.Name,MinTTL:CachePolicyConfig.MinTTL,QS:CachePolicyConfig.ParametersInCacheKeyAndForwardedToOrigin.QueryStringsConfig.QueryStringBehavior,Cookies:CachePolicyConfig.ParametersInCacheKeyAndForwardedToOrigin.CookiesConfig.CookieBehavior,Headers:CachePolicyConfig.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.HeaderBehavior}' \
  --output table

A QS or Cookies value of all on a static behavior is the first thing to fix. Behaviors with no cache policy at all are still on legacy ForwardedValues settings, which is worth migrating anyway.

If you must keep query strings in the key but want to drop tracking parameters, a viewer-request CloudFront Function can strip them before the cache lookup. It costs $0.10 per million invocations, so it only pays when the misses it prevents cost more than that:

function handler(event) {
  var qs = event.request.querystring;
  for (var key in qs) {
    if (key.indexOf('utm_') === 0 || key === 'gclid' || key === 'fbclid') {
      delete qs[key];
    }
  }
  return event.request;
}

Your origin won’t see those parameters any more. That’s fine when analytics run in the browser and not fine when the backend logs campaigns.

TTLs that match how content changes

Short TTLs force CloudFront to go back and ask whether an object changed. For build assets with a content hash in the file name (app.3f9a1c.js), send Cache-Control: max-age=31536000, immutable and never invalidate them; a new build produces a new name. For HTML and API responses that change, a short max-age plus stale-while-revalidate lets CloudFront serve the cached copy while it refreshes in the background.

Origin Shield, when misses are expensive

Origin Shield adds a cache layer in one Region, so requests from all of CloudFront’s regional caches collapse into one origin fetch. It bills $0.0075 per 10,000 requests that reach it in the US, and dynamic requests, including GETs with a TTL under 3,600 seconds, are always billed.

It earns its fee when viewers span continents and the origin is costly or fragile: just-in-time video packaging, on-the-fly image resizing, an on-premises server. It doesn’t for a mostly uncacheable API. A distribution proxying 100 million dynamic requests a month through Origin Shield pays $75 for a layer that caches nothing.

Turn on compression

CloudFront bills bytes served, so a smaller response is a cheaper one. Enable compression on the cache behavior and CloudFront will gzip or Brotli-compress responses such as HTML, CSS, JavaScript and JSON between 1,000 and 10,000,000 bytes, provided the origin sends a Content-Length header and hasn’t already compressed the response. Images, video and archives are already compressed and gain nothing.

One catch: objects already in the cache stay uncompressed until they expire or you invalidate them, so expect the saving to arrive gradually after the change.

Price classes

The default, Price Class All, serves from every edge location. Price Class 200 drops South America and Australia and New Zealand. Price Class 100 serves only from the United States, Canada, Mexico, Europe, Israel and Türkiye. Viewers outside the class are served from more distant edges inside it. If CloudFront occasionally serves from outside your class anyway, it bills the cheapest rate in the class.

Price Class 100 suits internal tools, admin panels, or anything whose audience really is in North America and Europe. It’s a bad idea for a consumer app with real users in São Paulo or Sydney, because the saving is a few cents per GB and the latency lands on paying customers.

Invalidations, logs and edge code

Invalidations are counted per path, across every distribution in the account. A wildcard such as /images/* counts as one path no matter how many files it clears. A pipeline that invalidates 40 individual files on each of 20 daily deploys submits 24,000 paths a month and pays $115 for 23,000 of them. Versioned file names remove most invalidations, and one wildcard per deploy covers the rest. Just don’t reach for /* by habit: it empties the cache and your hit ratio pays for it.

Real-time logs cost $0.01 per million lines, plus the Kinesis Data Streams capacity they land in, which is billed separately. Most teams only need real-time data for a sample. The SamplingRate setting accepts 1 to 100 percent:

# Read-only.
aws cloudfront list-realtime-log-configs \
  --query 'RealtimeLogConfigs.Items[].{Name:Name,SamplingRate:SamplingRate}' --output table

CloudFront Functions or Lambda@Edge. For header rewrites, redirects and URL normalization, Functions are far cheaper. At 300 million requests a month, a Function costs $29.80. The same logic in Lambda@Edge at 128 MB and an average of 5 ms costs $180 in requests plus $9.60 in duration. Lambda@Edge is the right tool when you need network calls, the request body or origin-facing triggers, and the wrong one for anything a Function can do.

Commitments and flat-rate plans

The CloudFront Security Savings Bundle is a one-year monthly spend commitment that saves up to 30%. Your commitment is 70% of the usage it covers: a steady $2,000 a month of CloudFront needs a $1,400 commitment, which also brings AWS WAF usage worth up to 10% of it. It applies to every CloudFront usage type, Functions and Lambda@Edge included. If usage falls to $1,200, you still pay $1,400, so size it to your low months and do the cache work first. It can’t be combined with a custom pricing agreement.

Flat-rate pricing plans charge a fixed monthly price per distribution (Free, Pro at $15, Business at $200, Premium from $1,000) with no overages and no other discounts on top; we compared the tiers in AWS billing discounts you probably missed. Read the fine print: a plan distribution can’t use legacy cache settings, real-time logs or origin access identity, custom caching rules start at Business, and its CloudFront Functions and web ACLs can’t be shared with other distributions. Moving a messy distribution onto a plan often means doing the cache policy migration anyway.

FAQ

Does CloudFront charge for data transfer from S3?

No. Origin fetches from S3, EC2, Elastic Load Balancing and VPC origins are free. You still pay S3 request charges for each GET a cache miss causes, and data sent from CloudFront to the origin, such as uploads, bills at $0.020/GB in the US.

How many CloudFront invalidations are free?

The first 1,000 paths a month per AWS account, shared across all distributions. After that each path costs $0.005. A wildcard path or a cache tag counts as one path however many objects it removes.

What is a good CloudFront cache hit ratio?

It depends on the content. Static assets with hashed file names should rarely miss, while personalized HTML may never cache well. Watch the trend rather than chasing a target, and when it drops after a release, split misses by path in the access logs and check what changed in the cache key.

This week: one distribution, one cache key

Pick your busiest distribution (the default Requests metric is free) and print its cache behaviors. For each one, write down what’s in the cache key and whether the origin really varies on it. That list usually points straight at the cookie or query string that’s quietly turning hits into misses. Once the obvious fixes are in, an automated scan can keep checking distributions for legacy cache settings and missing compression as new ones appear.