AWS CloudFront Invalidation — The Complete Guide

CloudFront caches your assets at edge locations worldwide, but when a file changes you need a reliable way to evict stale copies before the TTL expires — and doing that wrong costs money, time, or both.

When to Use CloudFront Invalidation

CloudFront invalidation is the right tool when you are already serving assets through a CloudFront distribution backed by an S3 origin and you need to force edge nodes to re-fetch specific paths immediately. Before reaching for invalidation, compare it to the alternatives covered across CDN purge strategies:

Scenario Best tool
Assets served through CloudFront + S3 CloudFront create-invalidation (this guide)
Assets served through Cloudflare Cloudflare cache rules and purge
Assets served through Fastly Fastly instant purge
Self-hosted Nginx reverse proxy Nginx cache purge for fingerprinted assets
Fingerprinted filenames in production Zero invalidations needed — see content hashing

The strongest advice in this guide: fingerprint your assets with a content hash and you almost never need to invalidate. When the filename changes, CloudFront treats it as a new object. You get instant cache coherence at zero cost and zero operational risk. Invalidation is a fallback for files you genuinely cannot rename — /index.html, /robots.txt, /manifest.json — or for emergency rollbacks before a deployment pipeline can cut a new hash.

When you do need invalidation, CloudFront is faster than many people expect. In practice, propagation across all edge POPs typically completes in under 60 seconds for most regions, though Amazon’s SLA is up to 10 minutes.

Prerequisites

  • AWS CLI v2.xaws --version should print aws-cli/2.*. Version 1 works but flag names for wait operations differ slightly.
  • Python 3.9+ and boto3 1.26+ for the programmatic examples (pip install boto3).
  • An existing CloudFront distribution. Export its ID once: export DIST_ID=E1ABCDEF2GHIJK — every snippet below uses this variable.
  • IAM permissions: cloudfront:CreateInvalidation, cloudfront:GetInvalidation, cloudfront:ListInvalidations on the distribution ARN, plus s3:GetObject / s3:ListBucket on the S3 bucket if you are configuring a new origin.
  • For S3 origins: Origin Access Control (OAC) rather than the legacy Origin Access Identity (OAI). OAC is the current AWS recommendation and supports SSE-KMS buckets.

Configuration Reference

These are the CloudFront cache-behavior fields most relevant to invalidation strategy. They appear in distribution configs as JSON (under CacheBehaviors[*] or DefaultCacheBehavior) and in the AWS console under the Cache behavior tab.

Field Type Default Effect
MinTTL integer (seconds) 0 Minimum time CloudFront caches an object. When set to 0, CloudFront honors Cache-Control: max-age and Expires headers from the origin. When > 0, CloudFront ignores those headers and caches for at least MinTTL seconds — invalidation becomes your only escape hatch.
DefaultTTL integer (seconds) 86400 Time CloudFront caches objects that have no Cache-Control or Expires header. Irrelevant when the origin always sends Cache-Control.
MaxTTL integer (seconds) 31536000 Caps how long CloudFront caches regardless of origin headers. An origin sending Cache-Control: max-age=99999999 is clamped to this value.
Compress boolean false Enables automatic gzip/Brotli compression at the edge. No cache-coherence impact but affects the object key when Accept-Encoding is in the cache policy.
QueryStringCaching string none Controls whether query strings are included in the cache key. Relevant if you use ?v=<hash> versioning instead of filename hashing — covered in cache key architecture.
OriginProtocolPolicy string https-only Protocol CloudFront uses to talk to the origin. S3 REST API endpoints require https-only.
ViewerProtocolPolicy string redirect-to-https Whether viewers can use HTTP. Does not affect caching but affects security posture.

The most critical interaction: MinTTL=0 is the configuration that allows fingerprinted assets to work harmoniously with Cache-Control: max-age=31536000, immutable. Your HTML files should use a DefaultTTL of 0 or very low (60 seconds), while fingerprinted JS/CSS/image files use max-age=31536000. See Cache-Control: immutable for the full header strategy, and Cache-Control immutable and TTL tuning for how to set TTLs per path pattern in CloudFront.

Step-by-Step Implementation

1. Export the distribution ID

Every command in this guide uses the DIST_ID environment variable. Set it once per shell session:

export DIST_ID=E1ABCDEF2GHIJK

Find your distribution ID in the AWS console under CloudFront → Distributions, or via:

aws cloudfront list-distributions \
  --query "DistributionList.Items[*].{ID:Id,Domain:DomainName,Status:Status}" \
  --output table

2. Create a simple invalidation via AWS CLI

Single path — the most targeted and cheapest form:

aws cloudfront create-invalidation \
  --distribution-id "$DIST_ID" \
  --paths "/index.html"

Wildcard for an asset directory — invalidates every object whose path starts with /assets/. Still counts as one invalidation path toward the monthly free tier:

aws cloudfront create-invalidation \
  --distribution-id "$DIST_ID" \
  --paths "/assets/*"

Full distribution sweep — use sparingly. /\* counts as one path but triggers re-fetching every cached object in the distribution. On large distributions this increases origin load significantly:

aws cloudfront create-invalidation \
  --distribution-id "$DIST_ID" \
  --paths "/*"

Multiple specific paths in one API call (counts as N paths, not 1):

aws cloudfront create-invalidation \
  --distribution-id "$DIST_ID" \
  --paths "/index.html" "/manifest.json" "/robots.txt" "/sw.js"

The CLI prints the invalidation ID and its InProgress status. CloudFront processes up to 15 invalidations concurrently per distribution. If you submit a 16th while 15 are running, the API returns a TooManyInvalidationsInProgress error.

3. Bash script with wait-for-completion

Deployments often need to block until invalidation finishes before updating DNS or running smoke tests. This script submits an invalidation and polls until Completed:

#!/usr/bin/env bash
set -euo pipefail

: "${DIST_ID:?DIST_ID must be set}"
PATHS=("$@")

if [[ ${#PATHS[@]} -eq 0 ]]; then
  echo "Usage: $0 /path1 /path2 ..." >&2
  exit 1
fi

echo "Creating CloudFront invalidation on $DIST_ID for: ${PATHS[*]}"

INVALIDATION_ID=$(aws cloudfront create-invalidation \
  --distribution-id "$DIST_ID" \
  --paths "${PATHS[@]}" \
  --query "Invalidation.Id" \
  --output text)

echo "Invalidation ID: $INVALIDATION_ID"
echo "Waiting for completion (polling every 10 s)..."

while true; do
  STATUS=$(aws cloudfront get-invalidation \
    --distribution-id "$DIST_ID" \
    --id "$INVALIDATION_ID" \
    --query "Invalidation.Status" \
    --output text)

  echo "  Status: $STATUS"

  if [[ "$STATUS" == "Completed" ]]; then
    echo "Invalidation complete."
    break
  fi

  sleep 10
done

Save as invalidate.sh, make it executable (chmod +x invalidate.sh), then call it from a CI step:

./invalidate.sh /index.html /manifest.json /sw.js

The AWS CLI also exposes aws cloudfront wait invalidation-completed which uses exponential backoff internally, but it times out after 10 minutes and can be opaque in CI logs. The explicit loop above gives you line-by-line status output.

4. boto3 Python script for programmatic invalidation

Use this in Lambda functions, Python-based deploy scripts, or anywhere you need programmatic control over invalidation batching:

#!/usr/bin/env python3
"""
cloudfront_invalidate.py — programmatic CloudFront invalidation via boto3.
Usage: python cloudfront_invalidate.py E1ABCDEF2GHIJK /index.html /manifest.json
"""

import sys
import time
import uuid
import boto3
from botocore.exceptions import ClientError


def create_invalidation(dist_id: str, paths: list[str]) -> str:
    """Submit a CloudFront invalidation and return the invalidation ID."""
    client = boto3.client("cloudfront")
    caller_ref = str(uuid.uuid4())  # Must be unique per request

    try:
        response = client.create_invalidation(
            DistributionId=dist_id,
            InvalidationBatch={
                "Paths": {
                    "Quantity": len(paths),
                    "Items": paths,
                },
                "CallerReference": caller_ref,
            },
        )
    except ClientError as exc:
        code = exc.response["Error"]["Code"]
        if code == "TooManyInvalidationsInProgress":
            raise RuntimeError(
                "15 concurrent invalidations already in progress. "
                "Wait for existing ones to complete or batch your paths."
            ) from exc
        raise

    inv_id = response["Invalidation"]["Id"]
    print(f"Created invalidation {inv_id} (CallerReference: {caller_ref})")
    return inv_id


def wait_for_completion(dist_id: str, inv_id: str, poll_interval: int = 10) -> None:
    """Poll until the invalidation status is Completed."""
    client = boto3.client("cloudfront")
    print(f"Waiting for invalidation {inv_id} to complete...")

    while True:
        response = client.get_invalidation(
            DistributionId=dist_id,
            Id=inv_id,
        )
        status = response["Invalidation"]["Status"]
        print(f"  Status: {status}")

        if status == "Completed":
            print("Invalidation complete.")
            return

        time.sleep(poll_interval)


def main() -> None:
    if len(sys.argv) < 3:
        print(f"Usage: {sys.argv[0]} <DIST_ID> <path1> [path2 ...]", file=sys.stderr)
        sys.exit(1)

    dist_id = sys.argv[1]
    paths = sys.argv[2:]

    inv_id = create_invalidation(dist_id, paths)
    wait_for_completion(dist_id, inv_id)


if __name__ == "__main__":
    main()

Run it:

python cloudfront_invalidate.py "$DIST_ID" /index.html /manifest.json

The CallerReference field must be unique per invalidation request. Using uuid.uuid4() is the standard pattern. If you retry the same CallerReference within 24 hours, CloudFront returns the original invalidation rather than creating a new one — useful for idempotent retries.

5. S3 origin with OAC — bucket policy

OAC (Origin Access Control) is the current recommended way to lock an S3 bucket to CloudFront-only access. The bucket must have Block Public Access enabled. The bucket policy grants read access only to your specific CloudFront distribution:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontOACReadAccess",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": [
        "s3:GetObject"
      ],
      "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT-ID:distribution/E1ABCDEF2GHIJK"
        }
      }
    }
  ]
}

Replace YOUR-BUCKET-NAME, ACCOUNT-ID, and E1ABCDEF2GHIJK with real values. Apply it:

aws s3api put-bucket-policy \
  --bucket YOUR-BUCKET-NAME \
  --policy file://bucket-policy.json

The OAC configuration on the CloudFront side references an OAC resource ID, which you create once per region:

OAC_ID=$(aws cloudfront create-origin-access-control \
  --origin-access-control-config '{
    "Name": "my-site-oac",
    "Description": "OAC for static site assets",
    "SigningProtocol": "sigv4",
    "SigningBehavior": "always",
    "OriginAccessControlOriginType": "s3"
  }' \
  --query "OriginAccessControl.Id" \
  --output text)

echo "OAC ID: $OAC_ID"

Then attach it to your distribution’s origin config via aws cloudfront update-distribution or in your infrastructure-as-code template.

6. Cache behavior TTL configuration

Apply per-path TTL policies by defining multiple cache behaviors. This JSON fragment is the shape used in aws cloudfront update-distribution --distribution-config:

{
  "CacheBehaviors": {
    "Quantity": 2,
    "Items": [
      {
        "PathPattern": "/assets/*",
        "TargetOriginId": "S3-YOUR-BUCKET-NAME",
        "ViewerProtocolPolicy": "redirect-to-https",
        "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
        "Compress": true,
        "MinTTL": 0,
        "DefaultTTL": 31536000,
        "MaxTTL": 31536000
      },
      {
        "PathPattern": "/index.html",
        "TargetOriginId": "S3-YOUR-BUCKET-NAME",
        "ViewerProtocolPolicy": "redirect-to-https",
        "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
        "Compress": true,
        "MinTTL": 0,
        "DefaultTTL": 60,
        "MaxTTL": 300
      }
    ]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "S3-YOUR-BUCKET-NAME",
    "ViewerProtocolPolicy": "redirect-to-https",
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
    "Compress": true,
    "MinTTL": 0,
    "DefaultTTL": 86400,
    "MaxTTL": 31536000
  }
}

CachePolicyId 658327ea-f89d-4fab-a63d-7e88639e58f6 is the AWS-managed CachingOptimized policy. Substitute your own policy ID if you have custom query-string or header requirements.

The two-behavior setup above gives fingerprinted assets under /assets/* a one-year TTL while keeping /index.html on a 60-second DefaultTTL so that deployments that only update the HTML propagate quickly even without an explicit invalidation.

CloudFront Invalidation Lifecycle Diagram

CloudFront Invalidation Lifecycle Browser GET /assets/app.js CloudFront POP Edge cache (IAD, LHR, SYD…) S3 Origin Bucket + OAC Canonical object request HIT — serve from POP MISS — fetch from origin object + Cache-Control header CACHE HIT CACHE MISS — — — invalidation flow below — — — aws cloudfront create-invalidation Control Plane Schedules propagation Status: InProgress → Completed API call POP — IAD object evicted POP — LHR object evicted propagate invalidation Propagation: typically <60 s in practice AWS SLA: up to 10 minutes Cost model First 1,000 paths/month free • $0.005 per path after Wildcard /* = 1 path regardless of objects invalidated Fingerprint = zero invalidations app.a1b2c3d4.js is a new object CloudFront never sees a stale version

Verification

After submitting an invalidation, confirm the edge is now serving fresh content. The x-cache response header from CloudFront tells you exactly what happened at the edge.

Check cache status for a specific asset:

curl -sI "https://d1234abcd.cloudfront.net/index.html" | grep -i "x-cache\|x-amz-cf-pop\|age\|cache-control"

Immediately after invalidation you should see:

x-cache: Miss from cloudfront
age: 0
cache-control: max-age=60
x-amz-cf-pop: IAD89-P3

On subsequent requests the header transitions to:

x-cache: Hit from cloudfront
age: 14

Check invalidation status by ID:

INVALIDATION_ID=I1ABCDEFGHIJKL

aws cloudfront get-invalidation \
  --distribution-id "$DIST_ID" \
  --id "$INVALIDATION_ID" \
  --query "Invalidation.{Status:Status,CreateTime:CreateTime}" \
  --output table

List all invalidations on a distribution:

aws cloudfront list-invalidations \
  --distribution-id "$DIST_ID" \
  --query "InvalidationList.Items[*].{ID:Id,Status:Status,CreateTime:CreateTime}" \
  --output table

Check how many are currently in progress (limit is 15 concurrent):

aws cloudfront list-invalidations \
  --distribution-id "$DIST_ID" \
  --query "length(InvalidationList.Items[?Status=='InProgress'])" \
  --output text

Confirm a fingerprinted asset bypasses the need entirely:

# New deploy produces app.a1b2c3d4.js — verify the new path is live
curl -sI "https://d1234abcd.cloudfront.net/assets/app.a1b2c3d4.js" | grep "x-cache"
# Expected: Miss from cloudfront (first request), then Hit from cloudfront

Edge Cases and Known Issues

The 15-concurrent-invalidation limit. CloudFront allows at most 15 in-progress invalidation requests per distribution at once. In high-velocity CI environments where multiple deploy pipelines run in parallel this limit is easy to hit. Mitigate it by batching all affected paths into a single create-invalidation call rather than one call per file, or by queuing deploys so invalidations do not pile up. The boto3 example above raises a clear RuntimeError when this limit is hit.

Wildcard patterns are prefix-anchored, not glob patterns. /assets/* invalidates everything under /assets/ but /assets/app.*.js is not a valid pattern — CloudFront wildcard syntax only supports a trailing *. You cannot use wildcards in the middle of a path. If you need to invalidate a subset of files with a shared prefix, structure your paths accordingly: /assets/js/*, /assets/css/*.

CallerReference uniqueness. If a boto3 call is retried (network timeout, Lambda retry) with the same CallerReference, CloudFront returns the existing invalidation response rather than creating a new one. This is intentional idempotency, but if you are building a retry loop you must track whether the returned invalidation is already Completed or was freshly created.

OAC and SSE-KMS. If your S3 bucket uses SSE-KMS encryption, the OAC service principal must have kms:Decrypt permission on the KMS key. Without it, CloudFront receives a 403 from S3, caches the error (briefly), and the invalidated path returns an error page rather than the updated object. Add the CloudFront service principal to the key policy.

MinTTL > 0 overrides Cache-Control. If you have set MinTTL to a non-zero value in a cache behavior, CloudFront ignores the origin’s Cache-Control: max-age and Expires headers and caches for at least that many seconds. This means that even after a successful invalidation, the very next request re-populates the cache for MinTTL seconds. For files you need to update frequently, keep MinTTL=0 and rely on the Cache-Control header from S3. See Cache-Control immutable and TTL tuning for the full picture.

Edge locations vs regional edge caches. CloudFront has two cache tiers: edge POPs and regional edge caches (RECs). An invalidation propagates to both. However, after invalidation, the next request hits the POP, misses, checks the REC, misses there too (if it was also invalidated), and only then fetches from origin. This double-miss means your origin may see a brief spike in requests across all regions simultaneously after a wildcard invalidation on a popular distribution.

S3 eventual consistency and invalidation timing. S3 is strongly consistent as of December 2020 for new objects and overwrites. A PutObject that completes before you call create-invalidation will be reflected correctly when CloudFront re-fetches. However, if you overwrite an S3 object and immediately invalidate without confirming the PutObject succeeded (e.g., in a pipeline with async steps), there is a race window where CloudFront might fetch the old object. Always ensure S3 writes complete before triggering invalidation.

Path case sensitivity. CloudFront path matching is case-sensitive. /Index.html and /index.html are different cache keys. Make sure your invalidation paths exactly match the paths your application uses.

Performance Impact

Invalidation propagation latency is the time between submitting the API call and all edge POPs having evicted the object. In practice this is typically 30–60 seconds for most regions. During propagation, some edge nodes serve stale content and others serve fresh content. For most web applications this is acceptable. For content with strict consistency requirements (payment confirmations, legal notices) consider serving those paths from the origin directly using a no-cache cache behavior rather than relying on invalidation.

Origin traffic spike. When a wildcard invalidation completes, every POP that receives a request for a matching path in the next few seconds makes an origin fetch. On a distribution serving hundreds of millions of monthly requests, this can mean thousands of simultaneous S3 GETs. S3 scales horizontally but if your origin has rate limits or you are using a custom HTTP server, add cache stampede protection: use shorter DefaultTTL values for frequently-changing paths rather than invalidating after every deploy.

Fingerprinted assets have zero performance cost. A fingerprinted asset — /assets/app.a1b2c3d4.js — is cached by CloudFront with max-age=31536000. When you publish a new build, the new hash produces a new URL. CloudFront treats it as a brand new object; no invalidation is submitted; the old URL naturally expires from edge caches when the TTL lapses (or immediately if it drops out of the LRU). User browsers that have the old HTML also have the old JS filename and serve the cached version locally — zero origin requests. Only users who load the new HTML get the new filename and fetch the new asset. This is covered in depth under content hashing.

Cost scaling. For a site with 50 deploys per month invalidating /index.html and 3 other files (4 paths per invalidation), monthly path count is 200 — well within the free 1,000 paths. At 500 deploys per month with 10 paths each that is 5,000 paths: 4,000 billable paths × $0.005 = $20/month. For very high-frequency deployments it is worth switching the non-HTML paths to fingerprinted filenames to avoid the accumulating cost entirely.

FAQ

What is the difference between an invalidation path and an invalidated object?

A path is what you submit in the API call; an object is each unique cached file that path matches. The path /assets/* is one path but might match 500 objects. CloudFront charges per path, not per object — so a wildcard invalidation that evicts 10,000 objects costs the same as one that evicts a single file. The free tier covers 1,000 paths per month; after that each additional path costs $0.005.

Can I use invalidation to roll back a bad deployment?

Yes, with caveats. If you overwrote /assets/app.js in S3 with a broken version, you can restore the previous version in S3 and then invalidate /assets/app.js to force CloudFront to re-fetch. But if your assets are fingerprinted, the broken file has a unique hash in its name (e.g., /assets/app.bad0cafe.js) while the previous good file is at a different URL (e.g., /assets/app.a1b2c3d4.js). Rolling back then means updating /index.html to reference the good URL and invalidating /index.html — one path, instant, zero risk of serving the broken asset again.

Why does my invalidation complete but users still see stale content?

Browser-level caching. If you served /index.html with Cache-Control: max-age=3600, the user’s browser cached it for an hour regardless of what you do on CloudFront. CloudFront invalidation only affects the edge cache; it cannot reach into user browsers. This is why HTML files should use short TTLs (max-age=60 or no-cache) while fingerprinted assets use long TTLs. See Cache-Control: immutable for the correct header strategy for each asset type.

How many hash characters should I use for fingerprinted filenames?

The convention is 8 hex characters (4 bytes of hash, 1 in 4 billion collision probability per content-identical pair). For monorepos or projects with thousands of chunks — where the birthday paradox makes collisions more likely — use 12–16 hex characters. Most build tools default to 8 (Vite, webpack) but expose a hashLength or [contenthash:12] option. The hash function is typically MD4 (webpack) or SHA-256 truncated (Vite, Rollup); any of these is collision-resistant at 8 characters for typical project sizes. The cache key architecture page covers the tradeoffs between hash length, filename length, and CDN key uniqueness guarantees.