Skip to content
Back to blog
5 min read

Caching for Consistency: A Balanced Static Site Strategy

Implementing a tiered caching strategy that balances high-performance delivery for assets with immediate consistency for content updates.


There are only two hard problems in Computer Science: cache invalidation, naming things, and off-by-one errors.

Jokes aside, caching forces a trade-off between performance and consistency.

In a static site, that trade-off becomes concrete. The application is simply a collection of files, and each file must be trusted for some period of time.

The tension between speed and freshness#

For this blog, the goal was to achieve two seemingly contradictory outcomes:

  1. High Performance: Assets like JavaScript, CSS, and fonts should be cached aggressively. Once a user has them, they should never have to download them again for that version of the site.
  2. Immediate Consistency: When a new post is published, it should be visible to users as quickly as possible without requiring manual cache clearing at the browser level.

This tension exists across multiple layers: the browser cache, the CloudFront edge, and the S3 origin.

A tiered caching approach#

The solution lies in differentiating content based on its volatility. We categorise the site's files into two groups and enforce different TTLs using CloudFront cache policies and path-based behaviours.

1. Immutable assets#

Files generated by Next.js in the _next/ build output include content hashes in their filenames. If the content of a CSS file changes, its filename changes too.

This immutability allows for an aggressive caching strategy. A dedicated CloudFront cache policy caches these paths at the edge for a year. A response headers policy injects Cache-Control: public, max-age=31536000, immutable so browsers cache them for the same duration. The immutable directive tells browsers that the resource will never change.

2. Mutable content#

Entry points like HTML files, the RSS feed (rss.xml), and the search index (search-index.json) do not have hashes in their names. These are the files that users request directly.

For mutable entry points, the default CloudFront behaviour uses a one-hour TTL at the edge. CloudFront also injects Cache-Control: public, max-age=3600, must-revalidate headers to align browser caching with the edge TTL, ensuring browsers cache these resources for the same duration. Since every deployment triggers a full CloudFront invalidation (/*), the hour-long TTL only governs caching between deploys. This maximises edge cache hit rates while still ensuring fresh content reaches visitors promptly after each publish.

Implementation via automation#

With TTLs enforced at CloudFront, the deploy step becomes a single S3 sync. There is no need for multi-pass uploads or metadata refreshes.

- name: Deploy to S3
  run: |
    aws s3 sync out/ s3://${{ secrets.S3_BUCKET }}/ --delete

CDN configuration#

The caching rules themselves are defined in Terraform using explicit cache policies, response headers policies, and path-based behaviours.

In Terraform, these rules are expressed using separate cache policies and response headers policies, attached to path-based behaviours. The /_next/* behaviour references the long-lived policies, while the default behaviour references the short-lived ones and attaches the URL rewrite function.

A simplified representation looks like this:

# Long-lived cache policy for content-hashed assets
resource "aws_cloudfront_cache_policy" "immutable_assets" {
  default_ttl = 31536000
  max_ttl     = 31536000
  min_ttl     = 31536000
}
 
# Cache policy for HTML and other entry points (1-hour TTL)
resource "aws_cloudfront_cache_policy" "html_short" {
  default_ttl = 3600
  max_ttl     = 3600
  min_ttl     = 3600
}
 
# Response headers policy for immutable assets
resource "aws_cloudfront_response_headers_policy" "immutable_assets" {
  custom_headers_config {
    items {
      header   = "Cache-Control"
      override = true
      value    = "public, max-age=31536000, immutable"
    }
  }
  # Security headers config omitted for brevity
}
 
# Response headers policy for mutable content
resource "aws_cloudfront_response_headers_policy" "mutable_content" {
  custom_headers_config {
    items {
      header   = "Cache-Control"
      override = true
      value    = "public, max-age=3600, must-revalidate"
    }
  }
  # Security headers config omitted for brevity
}
 
# Behaviour for immutable Next.js assets
ordered_cache_behavior {
  path_pattern                = "/_next/*"
  cache_policy_id             = aws_cloudfront_cache_policy.immutable_assets.id
  response_headers_policy_id  = aws_cloudfront_response_headers_policy.immutable_assets.id
}
 
# Default behaviour for HTML and other mutable content
default_cache_behavior {
  cache_policy_id            = aws_cloudfront_cache_policy.html_short.id
  response_headers_policy_id = aws_cloudfront_response_headers_policy.mutable_content.id
 
  function_association {
    event_type   = "viewer-request"
    function_arn = aws_cloudfront_function.rewrite.arn
  }
}

By defining TTLs at the CDN layer and using response headers policies to inject Cache-Control headers, both edge and browser caching behaviour are explicit and centralised. S3 is reduced to storage, while CloudFront becomes the control plane for performance and consistency.

Security at the delivery layer#

While caching focuses on performance, the delivery layer is also the right place to enforce security. We use an aws_cloudfront_response_headers_policy to apply security headers consistently at the CDN.

These include:

  • Strict-Transport-Security (HSTS): Ensuring all connections are encrypted. These parameters are configurable in Terraform. A lifecycle precondition enforces that include_subdomains is enabled when preload is set, matching the requirements of the HSTS preload list(opens in a new tab).
  • X-Content-Type-Options: Preventing MIME type sniffing.
  • X-Frame-Options: Mitigating clickjacking by preventing the site from being embedded in an iframe.
  • X-XSS-Protection: Legacy browser protection retained for compatibility.

By associating this policy with the CloudFront distribution, every response carries these protections, whether served from the edge cache or fetched from the origin.

Trade-offs and considerations#

This strategy involves a trade-off. By caching HTML at both the edge and in browsers for one hour, we accept a window of eventual consistency. If a user revisits the site shortly after a deployment, they might still see the previous version from their local browser cache until the hour expires.

However, because the deployment workflow also triggers a CloudFront invalidation (/*), the edge cache is cleared on every publish. The one-hour window only affects users who have already visited the site and are returning within that period. For a technical blog where posts are published infrequently, this is a reasonable compromise that significantly improves cache hit rates.

Next steps#

As the site grows, we can monitor the CloudFront cache hit ratio and adjust TTLs accordingly. Future refinements could include using stale-while-revalidate to decouple perceived freshness from latency. For now, this setup provides a production-grade foundation that is simple to operate and easy to reason about.