What it actually is
CloudFront is a content delivery network, but the useful way to think about it is smaller than that. It is a key-value cache that happens to have edge locations all over the world and a TLS terminator attached. A viewer asks for a URL, CloudFront computes a cache key from that request, and if it has an unexpired object stored under that key it answers immediately. If it does not, it asks your origin, stores the answer under the key, and answers the viewer.
Everything else in the product is a way to influence one of three things: what goes into the key, how long the stored answer lives, or what happens before and after the lookup. Cache policies shape the key. TTLs and Cache-Control headers shape the lifetime. Functions and Lambda@Edge run code around the lookup. If a CloudFront bill is too high or a site feels slow, the cause is nearly always that the key is wrong, and almost never that the network is.
The model
A distribution is the unit you configure. It has one or more origins (an S3 bucket, a load balancer, an API Gateway endpoint, any HTTP server, or a VPC origin), and one or more cache behaviors that match URL path patterns and decide which origin and which policies apply. A request that matches no specific behavior falls through to the default behavior.
The cache key is the part worth memorizing. The docs state the default plainly: the key includes the distribution domain name and the URL path, and "other values from the viewer request are not included in the cache key, by default". Query strings, headers and cookies only enter the key if a cache policy puts them there. A request for /story.html?ref=abc and one for /story.html?ref=xyz share a cache entry unless you said otherwise.
Two policy types control this, and confusing them is the most common configuration mistake. A cache policy decides which query strings, headers and cookies are part of the key, and therefore which requests can share an object. An origin request policy decides what extra values are forwarded to the origin on a miss without changing the key. If your origin needs a header for logging but does not vary its response by it, that header belongs in an origin request policy, never in the cache policy.
AWS ships managed cache policies so you do not have to design these on day one. The ones you will actually meet are CachingOptimized (ID 658327ea-f89d-4fab-a63d-7e88639e58f6, minimum TTL 1 second, default 24 hours, maximum 365 days, compression on) and CachingDisabled (ID 4135ea2d-6df8-44a3-9df3-4b5a84be39ad, all TTLs zero). There is a trap in the first one: the docs warn that policies with a minimum TTL above zero cache content for at least that long even if your origin sends Cache-Control: no-cache, no-store or private. With CachingOptimized the minimum is only one second, so the practical risk is low, but it is the kind of detail that matters when you move to a custom policy.
What AWS operates is the edge fleet, the TLS termination, the DDoS absorption and the routing. What you operate is the key design, the origin's behavior under miss traffic, and the security of the link between them. That last one is origin access control, the mechanism that lets CloudFront sign its requests to a private S3 bucket so the bucket never has to be public. It replaced the older origin access identity, and the bucket policy is scoped to a single distribution with an AWS:SourceArn condition.
Code can run at two points in the request path. CloudFront Functions handle viewer request and viewer response events, run JavaScript (the docs recommend JavaScript runtime 2.0), start in sub-millisecond time, and are meant for header manipulation, redirects, URL rewrites and cache key normalization. Lambda@Edge is the heavyweight alternative that runs Node.js or Python in a single Region and replicates around the world, for work that needs the network or more compute. A CloudFront Function cannot reach the network or the filesystem and cannot read the request body.
When to use it, when not to
You want | Use | Why |
|---|---|---|
Static assets, images, video segments close to viewers | CloudFront with an S3 origin | This is the original job |
Dynamic API responses with a short TTL | CloudFront in front of the API | Even a few seconds of caching collapses a traffic spike into one origin request |
Per-request logic that needs a database or network call | Lambda@Edge, or move it behind the origin | CloudFront Functions cannot make network calls |
Static IP addresses and TCP/UDP acceleration | A different service for that job | CloudFront is HTTP and HTTPS oriented; do not stretch it |
A website served only inside one Region to local users | Possibly just the origin | A CDN you cannot make cache anything is overhead, not acceleration |
The honest test for adopting CloudFront is whether a meaningful share of your responses can be shared between users. If every response is unique per user and cannot be cached, CloudFront still gives you TLS termination close to the viewer, DDoS protection and a stable front door, but it will not reduce your origin load, and you should say that out loud before you pay for it.
What it costs
CloudFront now has two billing models, and you choose per distribution. Prices below come from the AWS pricing pages at time of writing; check them before you commit, because they change.
Pay-as-you-go is the classic model. Data transfer out to the internet is free for the first 1 TB each month, then $0.085 per GB for the next 9 TB, $0.080 for the next 40 TB, stepping down to $0.020 per GB beyond 5 PB (the same table applies to the United States, Mexico, Canada, Europe, Israel and Turkiye). Requests are free for the first 10 million each month, then $0.0075 per 10,000 for HTTP and $0.0100 per 10,000 for HTTPS in the United States. CloudFront Functions cost $0.10 per million invocations after the always-free 2 million. Invalidations are free for the first 1,000 paths or tags each month, then $0.005 per path or tag. Origin Shield, the extra caching layer in front of your origin, costs $0.0075 per 10,000 requests in the United States. Fetching from any AWS origin into CloudFront is free.
Flat-rate plans are the newer option, and they change the shape of the bill. Each plan covers one distribution with one apex domain and has no overage charges:
Plan | Per month | Requests | Data transfer | Included S3 storage |
|---|---|---|---|---|
Free | $0 | 1M | 100 GB | 5 GB |
Pro | $15 | 10M | 50 TB | 50 GB |
Business | $200 | 125M | 50 TB | 1 TB |
Premium | $1,000 | 500M (configurable up to 6B) | 50 TB (configurable up to 600 TB) | 5 TB |
A flat-rate plan bundles more than delivery. The plan covers the distribution, the AWS WAF web ACL and its request fees, CloudWatch Logs ingestion for CloudFront and WAF logs, and an attached Route 53 hosted zone. The trade-offs are real: a WAF web ACL must stay associated, and features such as continuous deployment, real-time access logs and the legacy ForwardedValues configuration are unsupported. Lambda@Edge still works but its invocations are billed pay-as-you-go. If you blow through the allowance there is no surprise invoice, but the docs say sustained excess over two to three months can lead to delivery being adjusted, with email warnings at 50%, 80% and 100% of the allowance first.
The line item that surprises people is not on either pricing table. The cache hit ratio is not part of the default metrics: Requests, bytes downloaded, bytes uploaded and the 4xx, 5xx and total error rates are free, but CacheHitRate, OriginLatency and the per-status-code error rates are "additional metrics" that cost extra per distribution and must be switched on. You can run a distribution for a year and never see its hit ratio unless you turn that on or compute it from the logs.
The limits that bite
Per account, the quotas page lists 500 distributions, 100 origins per distribution, 75 cache behaviors per distribution and 100 alternate domain names per distribution. Custom cache policies are capped at 20 per account, and each policy can reference at most 10 query strings, 10 headers and 10 cookies. Those policy limits are the first wall you hit when you try to build a policy per route.
Performance quotas are generous but real: 250,000 requests per second per distribution and 150 Gbps of data transfer per distribution (neither applies to flat-rate distributions). Requests and origin responses are limited to 32,768 bytes of headers plus query string, and URLs to 8,192 bytes. The largest file CloudFront will cache from a single GET is 50 GB, and the maximum request body is 64 GB. CloudFront compresses files between 1,000 and 10,000,000 bytes, which means a 12 MB JSON export goes out uncompressed no matter what your policy says.
The origin timeouts are where outages hide. The origin response timeout is configurable from 1 to 120 seconds, the connection timeout from 1 to 10 seconds, connection attempts from 1 to 3, and keep-alive from 1 to 300 seconds. A slow endpoint that takes 70 seconds on a cold start is a 504 from the viewer's side unless you changed the timeout deliberately.
CloudFront Functions have their own ceiling. A function can be at most 10 KB, memory is capped at 2 MB, you can have 100 functions per account, and the function logs are truncated at 10 KB. Execution time is expressed as a "compute utilization" number from 0 to 100, where 100 means your function used all the time it is allowed. You see it in the test output, and a function that regularly sits near 100 is one to simplify before traffic grows.
Invalidations deserve their own warning. You get 150 paths or tags per second and one wildcard invalidation per second, and there is no limit on how many files a wildcard covers. But AWS's own guidance is that if you update files frequently you should version the file names instead: app.3f9c1e.js rather than app.js plus an invalidation. Versioning also reaches the browser caches and corporate proxies that invalidation cannot, which is why the docs list it first. Every time you invalidate, you are paying to fix a naming problem.
Build it
The goal is to demonstrate the central claim of this article. We will create a private S3 bucket, put a CloudFront distribution in front of it with origin access control, and deliberately choose a cache policy that includes every query string in the key. Then we will measure the hit ratio against traffic that carries marketing parameters. Finally we will attach a CloudFront Function that strips those parameters before the cache lookup, and measure again.
A note on what I verified: this is an unattended run with no AWS account attached. I checked every parameter shape in the scripts against the botocore service model with boto3 1.43, and I executed the CloudFront Function locally under Node 22 against three test events. I did not deploy a distribution for this article, so the hit ratios below are what the key arithmetic predicts, not a measurement. Run the scripts and treat your numbers as the truth.
Prerequisites and IAM
You need Python 3.9 or later, pip install boto3, and credentials for a sandbox account. The IAM identity needs the following actions. Scope them down further once it works.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cloudfront:CreateDistribution", "cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig", "cloudfront:UpdateDistribution",
"cloudfront:DeleteDistribution", "cloudfront:CreateOriginAccessControl",
"cloudfront:GetOriginAccessControl", "cloudfront:DeleteOriginAccessControl",
"cloudfront:CreateCachePolicy", "cloudfront:GetCachePolicy",
"cloudfront:DeleteCachePolicy", "cloudfront:CreateFunction",
"cloudfront:TestFunction", "cloudfront:PublishFunction",
"cloudfront:DescribeFunction", "cloudfront:DeleteFunction",
"s3:CreateBucket", "s3:PutBucketPublicAccessBlock", "s3:PutBucketPolicy",
"s3:DeleteBucketPolicy", "s3:PutObject", "s3:DeleteObject", "s3:DeleteBucket",
"sts:GetCallerIdentity"
],
"Resource": "*"
}
]
}
Step 1: the distribution with the wrong key
This script creates the bucket with all public access blocked, uploads one page, creates an origin access control that signs every origin request with SigV4, and creates a custom cache policy with QueryStringBehavior set to all. That setting is the mistake we are going to measure. It then creates the distribution and finally writes the bucket policy that lets this one distribution read the bucket.
import json, time, uuid, boto3
REGION = "us-east-1"
SUFFIX = uuid.uuid4().hex[:8]
BUCKET = f"cf-demo-{SUFFIX}"
s3 = boto3.client("s3", region_name=REGION)
cf = boto3.client("cloudfront") # global service
account = boto3.client("sts").get_caller_identity()["Account"]
s3.create_bucket(Bucket=BUCKET)
s3.put_public_access_block(
Bucket=BUCKET,
PublicAccessBlockConfiguration={
"BlockPublicAcls": True, "IgnorePublicAcls": True,
"BlockPublicPolicy": True, "RestrictPublicBuckets": True,
},
)
s3.put_object(Bucket=BUCKET, Key="docs/index.html", Body=b"<h1>hello from the origin</h1>",
ContentType="text/html", CacheControl="max-age=300")
oac = cf.create_origin_access_control(OriginAccessControlConfig={
"Name": f"oac-{SUFFIX}", "Description": "demo",
"SigningProtocol": "sigv4", "SigningBehavior": "always",
"OriginAccessControlOriginType": "s3",
})["OriginAccessControl"]["Id"]
policy = cf.create_cache_policy(CachePolicyConfig={
"Name": f"qs-all-{SUFFIX}", "MinTTL": 0, "DefaultTTL": 300, "MaxTTL": 3600,
"ParametersInCacheKeyAndForwardedToOrigin": {
"EnableAcceptEncodingGzip": True, "EnableAcceptEncodingBrotli": True,
"HeadersConfig": {"HeaderBehavior": "none"},
"CookiesConfig": {"CookieBehavior": "none"},
"QueryStringsConfig": {"QueryStringBehavior": "all"},
},
})["CachePolicy"]["Id"]
The second half creates the distribution and the bucket policy. The bucket policy is the one from the AWS documentation for read-only access, with the AWS:SourceArn condition pinning access to this distribution.
dist = cf.create_distribution(DistributionConfig={
"CallerReference": SUFFIX, "Comment": f"cache-key demo {SUFFIX}", "Enabled": True,
"DefaultRootObject": "",
"Origins": {"Quantity": 1, "Items": [{
"Id": "s3", "DomainName": f"{BUCKET}.s3.{REGION}.amazonaws.com",
"OriginAccessControlId": oac,
"S3OriginConfig": {"OriginAccessIdentity": ""},
}]},
"DefaultCacheBehavior": {
"TargetOriginId": "s3", "ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]},
"CachePolicyId": policy, "Compress": True,
},
})["Distribution"]
dist_id, domain = dist["Id"], dist["DomainName"]
s3.put_bucket_policy(Bucket=BUCKET, Policy=json.dumps({
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontServicePrincipalReadOnly", "Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject", "Resource": f"arn:aws:s3:::{BUCKET}/*",
"Condition": {"StringEquals": {
"AWS:SourceArn": f"arn:aws:cloudfront::{account}:distribution/{dist_id}"}},
}],
}))
json.dump(dict(bucket=BUCKET, oac=oac, policy=policy, dist_id=dist_id, domain=domain),
open("state.json", "w"), indent=2)
cf.get_waiter("distribution_deployed").wait(Id=dist_id)
print("deployed:", domain)
Step 2: measure the damage
The probe sends 60 requests. Three out of four carry utm_source and utm_content parameters with randomized values, which is what real campaign traffic looks like, and one in four is the clean URL. It reads the X-Cache header CloudFront returns on each response and prints the hit ratio.
import sys, json, random, urllib.request, collections
domain = json.load(open("state.json"))["domain"]
campaigns = ["news", "tw", "li", "yt", "ad1", "ad2", "ad3", "ad4"]
counts = collections.Counter()
for i in range(60):
q = f"?utm_source={random.choice(campaigns)}&utm_content={random.randint(1,40)}" if i % 4 else ""
with urllib.request.urlopen(f"https://{domain}/docs/index.html{q}") as r:
counts[r.headers["X-Cache"].split(" from")[0]] += 1
print(sys.argv[1], dict(counts), f"hit ratio {counts['Hit']/60:.0%}")
Save it as probe.py and run python probe.py before. Every distinct combination of parameters is a distinct cache key, because the policy put all query strings into the key. With 8 sources and 40 content values there are 320 possible keys, and 45 of your 60 requests are drawing from them. I simulated that draw 2,000 times and the expected hit ratio is about 28 percent, most of which comes from the clean URL. The other 72 percent of requests are misses that travel to S3 for an object that has not changed.
Step 3: the function that fixes the key
A viewer request function runs before the cache lookup, so whatever it does to the URI and query string is what the cache key is computed from. This function does two jobs. It removes tracking parameters (anything starting with utm_, plus fbclid and gclid), sorts what remains, and rebuilds the query string. The AWS docs say parameter order matters for the cache key, so ?color=red&size=large and ?size=large&color=red are two cached objects unless you normalize them; sorting makes the order consistent. It also maps directory-style URLs such as /docs/ to the index.html object that exists in the bucket, which is useful because S3 has no notion of a directory index.
function handler(event) {
var request = event.request;
// 1. Drop tracking parameters, sort the rest, so one object has one cache key.
var qs = request.querystring;
var names = Object.keys(qs).filter(function (name) {
return !/^(utm_|fbclid$|gclid$)/i.test(name);
}).sort();
var parts = [];
names.forEach(function (name) {
var entry = qs[name];
var values = entry.multiValue ? entry.multiValue : [entry];
values.forEach(function (v) { parts.push(name + '=' + v.value); });
});
request.querystring = parts.join('&');
// 2. Map directory-style URLs to the object that actually exists in S3.
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
} else if (uri.indexOf('.') === -1) {
request.uri = uri + '/index.html';
}
return request;
}
Save this as function.js (874 bytes, comfortably under the 10 KB limit). The function replaces the whole query string with a string, which the docs explicitly allow when you want to control parameter order. The multiValue branch handles repeated parameters, which the event structure collapses into a single field with an array.
I ran this locally against three events. A request for /docs with size, utm_source, color and gclid came out as /docs/index.html with the query string color=red&size=large. A request for / with no parameters became /index.html with an empty query string. A request for /app.js with a repeated q parameter kept both values and left the URI alone.
Now deploy it. The script creates the function with runtime cloudfront-js-2.0, tests it in the DEVELOPMENT stage with a sample event, publishes it to LIVE, adds it to the default behavior as a viewer-request association, and waits for the redeploy.
import json, boto3
cf = boto3.client("cloudfront")
s = json.load(open("state.json"))
fn = cf.create_function(
Name=f"normalize-{s['dist_id']}",
FunctionConfig={"Comment": "strip tracking params", "Runtime": "cloudfront-js-2.0"},
FunctionCode=open("function.js", "rb").read())
etag = fn["ETag"]
name = fn["FunctionSummary"]["Name"]
test = cf.test_function(Name=name, IfMatch=etag,
EventObject=open("event.json", "rb").read(), Stage="DEVELOPMENT")
print(test["TestResult"]["FunctionOutput"], "compute utilization:", test["TestResult"]["ComputeUtilization"])
arn = cf.publish_function(Name=name, IfMatch=etag)["FunctionSummary"]["FunctionMetadata"]["FunctionARN"]
cur = cf.get_distribution_config(Id=s["dist_id"])
cfg = cur["DistributionConfig"]
cfg["DefaultCacheBehavior"]["FunctionAssociations"] = {
"Quantity": 1, "Items": [{"FunctionARN": arn, "EventType": "viewer-request"}]}
cf.update_distribution(Id=s["dist_id"], IfMatch=cur["ETag"], DistributionConfig=cfg)
cf.get_waiter("distribution_deployed").wait(Id=s["dist_id"])
s["function"] = name
json.dump(s, open("state.json", "w"))
print("function attached")
The event.json file is a viewer-request event with /docs as the URI and four query parameters, in the structure the docs describe: lowercase header names, each query parameter an object with a value. The test step prints the function's output and its compute utilization, so you can see how close to the ceiling a 20 line function actually runs (it should be a small number).
Verify it works
Run python probe.py after. The same traffic pattern now collapses to a single cache key, because every request, with or without tracking parameters, reaches the cache lookup as /docs/index.html with an empty query string. The first request after the deployment is a miss, and the remaining 59 should be hits, which is a ratio of about 98 percent against the 28 percent expected before. If you see something between the two, check whether you ran the probe before the redeployment finished.
Look at one response by hand to confirm the mechanism instead of trusting the ratio. curl -sI "https://<domain>/docs/?utm_source=x&utm_content=1" | grep -i x-cache should say Hit from cloudfront, and so should the same request with a different utm_content. If the second one is a miss, the function is not attached to the behavior you are hitting.
To make the ratio permanent rather than a one-off probe, turn on the additional metrics. This is the call from the CloudFront docs, and it is the only way to get CacheHitRate into CloudWatch (in us-east-1, namespace AWS/CloudFront):
aws cloudfront create-monitoring-subscription --distribution-id <ID> \
--monitoring-subscription RealtimeMetricsSubscriptionConfig={RealtimeMetricsSubscriptionStatus=Enabled}
The estimated cost of following along is close to nothing. The demo moves a few kilobytes and a few dozen requests, far inside the always-free monthly allowances of 1 TB of transfer, 10 million requests and 2 million function invocations, so the CloudFront side is $0.00. S3 charges fractions of a cent for one object. The additional metrics subscription is the only item with a real price (a fixed per-metric monthly rate in CloudWatch), so skip it for a throwaway test or disable it before you finish.
When it breaks
A 403 AccessDenied XML document from S3. The bucket policy does not match the distribution. Check that the AWS:SourceArn carries the exact distribution ID and the right account ID, that the OAC is attached to the origin (OriginAccessControlId set, and OriginAccessIdentity empty), and that the object key exists. S3 returns 403 rather than 404 for a missing object when the caller lacks s3:ListBucket, which is why a typo in the path looks like a permissions failure.
PreconditionFailed on any update. CloudFront uses ETags for optimistic concurrency. Every update, publish or delete takes IfMatch with the ETag from the most recent read, and every successful change returns a new one. Re-fetch before you retry; do not reuse an ETag across calls.
The function is attached but nothing changes. A function must be published to the LIVE stage before it can be associated, and the association must be on the correct event type. Remember also that changing uri in a function does not change which cache behavior or origin the request uses; the behavior was chosen from the original URL. If your new URI would belong to a different behavior, it will not move.
The hit ratio is still low after the fix. The key may include something else. Check the cache policy for headers and cookies, and think about Accept-Language, which the docs call out: en-US,en, en,en-US and en-US all mean English and are cached as three objects. The docs also say to avoid User-Agent in the key and to avoid user-specific cookies, because each variation produces another copy of the object.
Delete fails with DistributionNotDisabled, or with an error about an associated function. A distribution must be disabled and fully deployed before it can be deleted, and a function must be removed from the distribution config before it can be deleted. The cleanup script below does these in the right order.
Cleanup
Delete in dependency order: disable the distribution and detach the function, wait for the change to deploy, delete the distribution, then the function, the cache policy and the origin access control, and finally empty and delete the bucket. If you subscribed to additional metrics, delete that subscription too with aws cloudfront delete-monitoring-subscription --distribution-id <ID>.
import json, boto3
cf, s3 = boto3.client("cloudfront"), boto3.client("s3")
s = json.load(open("state.json"))
cur = cf.get_distribution_config(Id=s["dist_id"])
cfg = cur["DistributionConfig"]; cfg["Enabled"] = False
cfg["DefaultCacheBehavior"]["FunctionAssociations"] = {"Quantity": 0}
cf.update_distribution(Id=s["dist_id"], IfMatch=cur["ETag"], DistributionConfig=cfg)
cf.get_waiter("distribution_deployed").wait(Id=s["dist_id"])
cf.delete_distribution(Id=s["dist_id"],
IfMatch=cf.get_distribution_config(Id=s["dist_id"])["ETag"])
if s.get("function"):
cf.delete_function(Name=s["function"],
IfMatch=cf.describe_function(Name=s["function"])["ETag"])
cf.delete_cache_policy(Id=s["policy"], IfMatch=cf.get_cache_policy(Id=s["policy"])["ETag"])
cf.delete_origin_access_control(Id=s["oac"],
IfMatch=cf.get_origin_access_control(Id=s["oac"])["ETag"])
s3.delete_object(Bucket=s["bucket"], Key="docs/index.html")
s3.delete_bucket_policy(Bucket=s["bucket"])
s3.delete_bucket(Bucket=s["bucket"])
print("all deleted")
Confirm the bill is zero by checking that aws cloudfront list-distributions no longer shows your distribution and that aws s3 ls no longer shows the bucket. If you moved a distribution onto a flat-rate plan at any point, remember the rule from the docs: you cannot delete a distribution that has a plan, you must cancel the plan first, and disabling a distribution does not stop plan charges. A paid plan keeps billing until the end of the billing cycle after you cancel it.
The takeaway
The practical rule is short. Put into the cache key only the values that change the response, normalize the ones that arrive in many spellings, and use versioned file names so you never need to invalidate. Then turn on the metric that tells you whether it worked, because the default dashboard will not. A CDN that is configured this way is cheap and fast. A CDN whose key includes every marketing parameter is an expensive way to forward traffic to your origin with extra steps.

