paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

November 25, 2019

AWS CloudFront Explained: From First Distribution to Production-Grade Edge Architecture

A practical, engineering-first guide to what CloudFront is, how it actually works under the hood, and when it belongs in your architecture

Introduction

Most engineers meet CloudFront the same way: a ticket says "put a CDN in front of this," someone clicks through the AWS console, picks an S3 bucket as an origin, and moves on. It works, latency drops, and the ticket closes. But CloudFront is not just a caching layer that happens to live in front of your origin - it's a globally distributed compute and delivery platform with its own request lifecycle, its own caching semantics, and its own failure modes that only show up once you're operating at scale or need something more sophisticated than "cache the static files."

This article is written for engineers who already know CloudFront exists and want to actually understand it: how a request travels through the system, why cache invalidation behaves the way it does, when Lambda@Edge is the wrong tool and CloudFront Functions is the right one, and how to reason about the trade-offs instead of copying a Terraform module from a blog post. We'll move from fundamentals to the parts that only become visible in production - origin failover, cache key design, signed URLs, and the cost and latency trade-offs that determine whether CloudFront is even the right answer for a given workload.

What CloudFront Is and the Problem It Solves

At its core, CloudFront is Amazon's Content Delivery Network (CDN): a globally distributed network of edge locations that cache and serve content closer to end users than your origin server ever could. The fundamental problem it solves is physics, not software - the speed of light imposes a hard floor on how fast a request can travel from Tokyo to a single origin server in us-east-1, no matter how well-optimized that origin is. CloudFront's answer is to replicate the delivery point, not the origin itself, across a network of points of presence (PoPs) that AWS operates in hundreds of cities worldwide.

It's worth being precise about what CloudFront actually caches and what it doesn't. CloudFront sits in front of an origin - which can be an S3 bucket, an Application Load Balancer, an EC2 instance, an on-premises server, or any HTTP(S) endpoint reachable from the internet - and it caches responses according to rules you define. Static assets like images, CSS, and JavaScript bundles are the obvious use case, but CloudFront is equally capable of accelerating dynamic content through TCP and TLS connection reuse, persistent connections to the origin, and route optimization across AWS's private backbone network, even when the actual response is never cached.

The "why CloudFront" question usually comes down to three overlapping motivations: latency reduction for geographically distributed users, origin offload so your backend doesn't melt under traffic spikes, and a security perimeter - CloudFront integrates natively with AWS WAF, AWS Shield, and ACM-issued TLS certificates, which means a large share of malicious or abusive traffic never reaches your application layer at all. Compared to alternatives like Cloudflare, Fastly, or Akamai, CloudFront's main advantage is depth of integration with the rest of AWS: IAM-based access control to S3 origins, direct integration with Lambda for edge compute, and billing that lives inside the same AWS account, which matters a great deal for organizations already committed to the AWS ecosystem.

How CloudFront Works Under the Hood

To reason about CloudFront correctly, you need a mental model of the actual request path, because the naive "browser talks to CloudFront, CloudFront talks to origin" picture hides two important layers. When a user makes a request, DNS resolves your CloudFront domain (or custom domain via a CNAME/ALIAS record) to the IP of the nearest edge location, determined by Amazon's global network using anycast-style routing and latency measurements - not necessarily the geographically nearest one, but the one with the best measured performance. This is the first layer: edge locations, of which AWS operates hundreds, are the outermost tier and the ones end users actually connect to.

The second layer, often overlooked, is the regional edge cache. These are a smaller number of larger caches positioned between edge locations and your origin. When an edge location experiences a cache miss, it doesn't necessarily go straight to your origin - it first checks the regional edge cache, which has a larger storage capacity and holds a broader working set of content. Only if both the edge location and the regional edge cache miss does the request travel all the way to the origin. This two-tier structure is why cache hit ratios on infrequently accessed content can still be reasonably high, and why understanding TTLs matters at two levels, not one.

Distributions are the core CloudFront resource: a configuration object that ties together one or more origins, one or more cache behaviors (path-pattern-based routing rules), and settings like TLS certificates, logging, and geographic restrictions. Each cache behavior specifies which origin handles matching requests and, critically, a cache policy and an origin request policy - two separate constructs introduced in 2020 that replaced the older, more conflated "forward headers/cookies/query strings" model. The cache policy determines what parts of the request (headers, cookies, query strings) are included in the cache key, which directly determines what counts as a cache hit versus miss. The origin request policy determines what additional information CloudFront forwards to the origin, independent of whether that information affects caching. Conflating these two is one of the most common sources of CloudFront misconfiguration - including a header in the cache key that has high cardinality (like a session ID) can silently collapse your cache hit ratio to near zero.

Cache invalidation is the last piece of the mental model, and it behaves differently from what many engineers expect coming from simpler caching systems. Calling the invalidation API doesn't purge content instantly and doesn't rely on the object's TTL - it explicitly tells every edge location holding a matching path pattern to treat cached copies as stale on the next request. Invalidations are not free at high volume (the first 1,000 paths per month are included, then billed per path), which is why the more scalable pattern for frequently changing content is cache-busting through versioned file names or query strings rather than relying on invalidation calls at deploy time.

Setting Up CloudFront: Practical Implementation

The cleanest way to provision CloudFront in a real engineering organization is through infrastructure as code, since distributions have dozens of interdependent settings that are easy to get subtly wrong through console clicking. The example below uses AWS CDK in TypeScript to provision a distribution in front of a private S3 bucket, using Origin Access Control (OAC) - the modern replacement for the older Origin Access Identity (OAI), which AWS now recommends for all new distributions because it supports SigV4 signing for all S3 request types, including those using AWS KMS-encrypted objects.

import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import { Construct } from "constructs";

export class StaticSiteStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const siteBucket = new s3.Bucket(this, "SiteBucket", {
      bucketName: "example-app-static-assets",
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
    });

    // Origin Access Control replaces the legacy OAI approach
    const distribution = new cloudfront.Distribution(this, "SiteDistribution", {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        compress: true,
      },
      additionalBehaviors: {
        "/api/*": {
          origin: new origins.HttpOrigin("api.example.com"),
          cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
          originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
          allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        },
      },
      defaultRootObject: "index.html",
      priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
      httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
    });

    new cdk.CfnOutput(this, "DistributionDomain", {
      value: distribution.distributionDomainName,
    });
  }
}

Note the second cache behavior for /api/*: it disables caching entirely and forwards nearly all viewer information to the origin, which is the correct pattern when CloudFront is being used purely for TLS termination, connection reuse, and WAF protection in front of a dynamic API, rather than as a cache. Mixing cached static content and uncached dynamic content in a single distribution - routed by path pattern - is one of the most common and effective CloudFront architectures, since it avoids running two separate CDNs or exposing your API origin directly to the internet.

Operational tasks like invalidations are usually scripted rather than clicked through the console, particularly in CI/CD pipelines where a deployment needs to guarantee fresh content is served immediately. The following Python example, using boto3, shows a realistic deploy-time invalidation that targets only the paths that actually changed rather than wildcarding the entire distribution, which keeps invalidation costs predictable:

import boto3
from datetime import datetime, timezone

cloudfront = boto3.client("cloudfront")

def invalidate_paths(distribution_id: str, changed_paths: list[str]) -> str:
    response = cloudfront.create_invalidation(
        DistributionId=distribution_id,
        InvalidationBatch={
            "Paths": {
                "Quantity": len(changed_paths),
                "Items": changed_paths,
            },
            # CallerReference must be unique per invalidation request
            "CallerReference": f"deploy-{datetime.now(timezone.utc).isoformat()}",
        },
    )
    return response["Invalidation"]["Id"]

if __name__ == "__main__":
    invalidate_paths(
        distribution_id="E1EXAMPLE12345",
        changed_paths=["/index.html", "/manifest.json", "/assets/app-*.js"],
    )

Advanced CloudFront: Edge Compute and Origin Optimization

Once the basics of caching and routing are in place, CloudFront's more advanced capability is running actual code at the edge, and AWS gives engineers two distinct tools for this that are frequently confused: CloudFront Functions and Lambda@Edge. CloudFront Functions are lightweight, written only in a restricted subset of JavaScript, execute in a sub-millisecond runtime directly at the edge location, and are designed for high-volume, simple operations - URL rewrites, header manipulation, redirects, and basic authorization checks. Lambda@Edge, by contrast, runs actual Node.js or Python Lambda functions, supports longer execution times and network calls, can run at either the viewer-request/response or origin-request/response stage, and is appropriate for tasks like A/B testing logic, image resizing on the fly, or authentication that requires calling an external service. The trade-off is cost and latency: Lambda@Edge is meaningfully more expensive per invocation and slower to cold-start than a CloudFront Function, so reaching for it when a CloudFront Function would suffice is a common and avoidable inefficiency.

// CloudFront Function: normalize URLs and enforce a canonical trailing-slash policy
// Runs at the viewer-request stage, executes in microseconds, no network access allowed
function handler(event) {
  var request = event.request;
  var uri = request.uri;

  // Redirect directory-style requests to index.html without a round trip to origin
  if (uri.endsWith("/")) {
    request.uri += "index.html";
  } else if (!uri.includes(".")) {
    request.uri += "/index.html";
  }

  return request;
}

Beyond compute, Origin Shield is the advanced-tier feature that addresses a scaling problem invisible at low traffic: without it, every regional edge cache that experiences a miss can independently hit your origin simultaneously, creating a thundering-herd effect during a viral traffic spike or a cache-wide expiration. Origin Shield inserts an additional caching layer - a single AWS region you designate, ideally the one nearest your origin - that all regional edge caches must go through on a miss, consolidating concurrent origin requests into far fewer actual connections. It adds a small amount of latency on cache misses in exchange for meaningfully reducing origin load, which is a trade worth making for any origin that isn't trivially horizontally scalable, such as a legacy monolith or a database-backed dynamic endpoint.

Analogies and Mental Models

If you want a single mental model for CloudFront, think of it as a chain of increasingly local libraries rather than a single warehouse. Your origin is the national archive - comprehensive, authoritative, but far away and expensive to query repeatedly. Regional edge caches are large regional libraries that keep a broad but not complete copy of popular material. Edge locations are the small branch library on your street corner: fast, convenient, but limited in what it stocks. A request for a book "checks" the corner branch first, then the regional library, and only travels to the national archive if nobody closer has a copy - and once it's been fetched, copies get placed on the closer shelves so the next reader doesn't have to wait.

This analogy also clarifies why cache key design matters so much: imagine if the corner library catalogued every book not just by title, but by title plus the exact time the reader walked in. Every request would look unique, nothing would ever be reused, and every single "checkout" would require a trip to the national archive. That's precisely what happens when a cache policy includes a high-cardinality header or cookie in the cache key - you've technically built a CDN, but you've configured it in a way that guarantees it behaves like a pass-through proxy instead of a cache.

Trade-offs and Common Pitfalls

CloudFront's flexibility is also its biggest source of misconfiguration, and the pitfalls tend to cluster around a few recurring mistakes. The most common is over-inclusive cache keys, discussed above, where well-intentioned attempts to personalize responses (including a user's locale, auth token, or session cookie in the cache key) quietly destroy the cache hit ratio the entire distribution exists to provide. A closely related mistake is forgetting that CloudFront caches error responses by default unless you explicitly configure custom error caching TTLs - a transient 502 from a struggling origin can get cached and served to every subsequent user in that edge location's catchment for the default error TTL, turning a brief origin blip into a much longer visible outage.

Cost is a second, less obvious trade-off. CloudFront pricing is a function of data transfer out (which varies significantly by geographic region - data transfer to users in South America or parts of Asia costs meaningfully more per gigabyte than North America or Europe), the number of HTTP/HTTPS requests, and any invalidation requests beyond the free tier. The PriceClass setting lets you restrict which edge locations serve your content in exchange for lower cost, but choosing PriceClass_100 when you have a genuinely global user base means users in excluded regions get routed to farther-away edge locations, silently reintroducing the latency problem CloudFront was meant to solve. Engineers frequently set price class once at setup time and never revisit it as their user base geography shifts.

The third pitfall is treating CloudFront as a security boundary without actually configuring it as one. A private S3 bucket behind a CloudFront distribution without properly configured Origin Access Control can still be reachable directly via its S3 URL if bucket policies aren't locked down, defeating the purpose of putting a CDN in front of it. Similarly, teams often assume HTTPS-only viewer protocol policy is enough, without also enforcing HTTPS on the origin-facing connection (OriginProtocolPolicy), leaving an unencrypted hop between CloudFront and the origin - a real exposure for any origin outside AWS's private network boundary.

Best Practices for Production CloudFront Deployments

Getting CloudFront right in production comes down to a handful of disciplined habits rather than exotic configuration. Separate cache behaviors by content type and access pattern rather than trying to force one behavior to serve everything - static assets, API traffic, and user-uploaded content each deserve their own path pattern, cache policy, and TTL strategy, even within a single distribution. Use versioned or content-hashed filenames for static assets (app.a1b2c3.js rather than app.js) so you can set extremely long TTLs - a year or more - without ever needing an invalidation, and reserve invalidations for the rare cases where content must change in place at a fixed URL.

Instrument before you optimize: CloudFront's standard access logs and, for lower-latency needs, real-time logs delivered to Kinesis Data Streams, give you the cache hit ratio, origin latency, and error rate data needed to make informed decisions about cache policy and Origin Shield placement, rather than guessing. Pair this with CloudWatch alarms on 4xx/5xx error rates and origin latency, since a misbehaving origin combined with aggressive error caching is exactly the failure mode described earlier, and catching it in minutes rather than hours materially changes the blast radius of an incident. Finally, treat distribution configuration as code from day one - the number of interdependent settings (cache policies, origin request policies, behaviors, certificates, WAF associations) makes console-driven changes a reliability risk once more than one engineer touches the distribution.

Key Takeaways

Conclusion

CloudFront rewards the engineer who understands its request lifecycle and punishes the one who treats it as a black box you point at an origin and forget. The difference between a distribution that meaningfully improves latency and offloads your backend, and one that silently behaves like a slow, expensive pass-through proxy, usually comes down to a handful of decisions made at configuration time: how the cache key is built, whether Origin Shield is protecting a fragile origin, and whether edge compute is scoped to the lightest tool that can do the job.

None of this requires exotic knowledge - it requires reading the request path carefully and being deliberate about the handful of settings that actually determine caching behavior. Start with the fundamentals covered here, instrument your distribution so you can see cache hit ratio and origin health directly, and treat the advanced features - Lambda@Edge, Origin Shield, field-level encryption, signed URLs - as tools you reach for when a specific, observed problem calls for them, not as boxes to check on day one.

References

A quick flashcard preview - one example of each question type in this post. This is a demo of what you'll find in the full quiz app.

Flashcard preview - 1 / 9Multiple Choice

multiple choice - advanced - auto-graded

A team is migrating a distribution's private S3 origin security configuration. Which mechanism should they use for a new distribution, and why?

Choose an answer