CDN TTL Behavior on Cloudflare, Fastly and CloudFront

After working through this guide you will be able to predict, for one fixed origin response, exactly how long Cloudflare, Fastly and AWS CloudFront will each hold the object at the edge — and to prove that prediction with curl -I instead of guessing from a dashboard. You will know which directives each provider actually parses, where each one lets you override the origin, what happens to error responses that carry no freshness at all, and which setting wins when the header and the console give contradictory instructions.

The uncomfortable truth of multi-CDN work is that Cache-Control is not a contract, it is a suggestion that three different parsers interpret three different ways. The same 200 response that lives for ten minutes on one edge can live for an hour on another and be refused entirely by a third, without a single byte changing at the origin. Everything below is anchored to one reference response so the differences are attributable to the CDN rather than to your application. The directive semantics themselves — what s-maxage means versus max-age, why no-cache still stores — are covered in the parent guide on Cache-Control and CDN TTL; this guide is only about the divergence between implementations.

Key objectives:

  • Fix one reference origin response and trace it through all three edge TTL calculators.
  • Map which directives each provider parses, ignores, or silently reinterprets.
  • Locate each provider’s override surface and learn which side wins a header-versus-console conflict.
  • Verify the resulting lifetime from the response headers, and migrate between providers without a TTL regression.

Prerequisites

You need read access to at least one of the three consoles and a shell with curl 7.68 or newer. Terraform 1.5+ is assumed for the CloudFront example, and the Fastly example is plain VCL you can paste into a custom snippet. Confirm your tooling and that you are actually hitting an edge rather than the origin:

curl --version | head -1              # curl 8.5.0
terraform version | head -1           # Terraform v1.9.x
dig +short assets.example.com         # must resolve to CDN anycast space

Do the whole exercise on one throwaway path (/ttl-probe.json) that no other traffic touches. A shared path warms and ages unpredictably, and every measurement below depends on knowing when the object entered the cache.

The reference origin response

Every comparison in this guide uses this exact response, served for GET /ttl-probe.json:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=120, s-maxage=600, stale-while-revalidate=60
Vary: Accept-Encoding
ETag: "9f2c-probe"

It is deliberately ordinary: a public JSON document, a short browser lifetime, a ten-minute shared lifetime, and a one-minute grace window for background refresh. Nothing exotic, nothing vendor-specific. Here is what each edge does with it under default configuration.

One response, three edge TTL calculators A single origin response carrying max-age, s-maxage and stale-while-revalidate fans out to Cloudflare, Fastly and CloudFront, each deriving its own edge lifetime and grace behavior. Same origin response, three edge lifetimes Origin response for /ttl-probe.json Cache-Control: public, max-age=120, s-maxage=600, stale-while-revalidate=60 Vary: Accept-Encoding Cloudflare respect_origin mode edge TTL 600s browser TTL 120s stale via Cache Rule Fastly no Surrogate-Control edge TTL 600s browser TTL 120s 60s revalidate window CloudFront policy min 0 / max 86400 edge TTL 600s browser TTL 120s grace window ignored Identical bytes at origin. Every difference below comes from the edge TTL calculator, not the application.

The headline lifetimes agree because all three read s-maxage and none of the defaults clamp it. The divergence starts the moment you change one variable: raise the CloudFront MinTTL, drop s-maxage, return a 404 instead of a 200, or add no-store. Each of those flips at least one provider onto a different code path.

Which directives each provider parses

Directive Cloudflare Fastly CloudFront
s-maxage Read, beats max-age for edge TTL Read, beats max-age, loses to Surrogate-Control Read, beats max-age, then clamped to min/max
max-age Edge + browser TTL when nothing more specific Edge + browser TTL fallback Edge TTL fallback, always the browser value
CDN-Cache-Control Read, highest header precedence Read, highest header precedence Not read; use the cache policy
Surrogate-Control Read, above s-maxage Read, authoritative for edge TTL Not read
stale-while-revalidate Configured through Cache Rules serve-stale settings Honored natively, also settable as beresp.stale_while_revalidate Ignored
no-store Response is not stored, status BYPASS Response becomes a pass, never stored Ignored when MinTTL is greater than 0
private Not stored at the shared edge Becomes a pass Ignored when MinTTL is greater than 0
no-cache Stored, revalidated on every use Stored with a zero TTL, revalidated Ignored when MinTTL is greater than 0
immutable Passed through to the browser Passed through to the browser Passed through to the browser

Three rows deserve emphasis. First, CDN-Cache-Control is the cleanest cross-provider tool you have on Cloudflare and Fastly, and it is invisible on CloudFront — a policy expressed only in that header will silently fall back to s-maxage on AWS. Second, stale-while-revalidate is not portable: a design that leans on a grace window for origin protection loses that cushion entirely on CloudFront, so read serving stale content with stale-while-revalidate before assuming the behavior travels. Third, the no-store row is the single most dangerous asymmetry in the table: CloudFront honors the opt-out directives only when the cache policy’s MinTTL is zero. Set a floor of even one second and CloudFront will happily cache a response the origin explicitly forbade caching.

Where each provider overrides you

Every provider offers a console-side lever that outranks the origin header. Knowing exactly where that lever sits is the difference between a five-minute fix and an afternoon of confused header archaeology.

Cloudflare — Cache Rules and Edge TTL mode

Cloudflare’s edge TTL is a two-mode switch inside a Cache Rule. In respect_origin mode the edge walks the header precedence chain; in override_origin mode it ignores freshness headers entirely and applies the number you supply. The same rule carries a status_code_ttl list that assigns lifetimes to responses the origin sent without any directive.

{
  "description": "Probe path: 10 min edge, 2 min browser, short TTL on errors",
  "expression": "(http.request.uri.path eq \"/ttl-probe.json\")",
  "action": "set_cache_settings",
  "action_parameters": {
    "cache": true,
    "edge_ttl": {
      "mode": "respect_origin",
      "default": 600,
      "status_code_ttl": [
        { "status_code": 404, "value": 30 },
        { "status_code_range": { "from": 500, "to": 599 }, "value": 0 }
      ]
    },
    "browser_ttl": { "mode": "respect_origin" },
    "serve_stale": { "disable_stale_while_updating": false }
  }
}

The default value only applies when the origin sends nothing usable; in override_origin mode it applies unconditionally. serve_stale is where Cloudflare exposes grace behavior — leaving disable_stale_while_updating at false lets the edge answer from an expired copy while it refreshes in the background, which is the closest analogue to the origin’s stale-while-revalidate=60.

Fastly — beresp.ttl in VCL

Fastly hands you the calculator itself. By the time vcl_fetch runs, beresp.ttl already holds the value Fastly derived from Surrogate-Control, then s-maxage, then max-age, then the service default. Anything you assign after that point is final.

sub vcl_fetch {
  # beresp.ttl already equals 600s here, derived from s-maxage.

  # Error responses: keep them briefly so a broken origin is not hammered.
  if (beresp.status >= 500 && beresp.status < 600) {
    set beresp.ttl = 5s;
    set beresp.stale_if_error = 60s;
    return(deliver_stale);
  }

  if (beresp.status == 404 || beresp.status == 410) {
    set beresp.ttl = 30s;
  }

  # Make the grace window explicit rather than relying on header parsing.
  set beresp.stale_while_revalidate = 60s;

  # Never leak the edge-only header to the browser.
  unset beresp.http.Surrogate-Control;
  return(deliver);
}

The service-level “Default TTL” in the Fastly console is the value beresp.ttl starts at when the origin sends no freshness directive at all — 3600 seconds on a new service. It is a fallback, not a ceiling: an origin sending s-maxage=600 still gets 600. Because VCL runs last, a snippet that unconditionally sets beresp.ttl makes every header in the response cosmetic, which is by far the most common cause of “Fastly is ignoring my Cache-Control” reports.

CloudFront — cache policy min, default and max TTL

CloudFront does not read headers so much as clamp them. The attached cache policy defines three numbers, and the origin’s s-maxage/max-age is only honored inside that window.

resource "aws_cloudfront_cache_policy" "probe" {
  name        = "probe-ttl-policy"
  min_ttl     = 0          # 0 is required for no-store/private/no-cache to be honored
  default_ttl = 600        # used when the origin sends no freshness directive
  max_ttl     = 86400      # ceiling: a longer s-maxage is truncated to this

  parameters_in_cache_key_and_forwarded_to_origin {
    enable_accept_encoding_brotli = true
    enable_accept_encoding_gzip   = true
    query_strings_config { query_string_behavior = "none" }
    headers_config       { header_behavior       = "none" }
    cookies_config       { cookie_behavior       = "none" }
  }
}

Read the three numbers as a clamp expression: effective_ttl = clamp(origin_ttl, min_ttl, max_ttl), with default_ttl substituted for origin_ttl when the origin is silent. A MinTTL of 3600 against an origin sending max-age=60 produces an hour of edge caching and no warning anywhere. Note also that the cache policy defines the cache key as well as the lifetime, so tightening the key and changing the TTL in the same apply makes a regression impossible to attribute — change one at a time, and handle key work separately using normalizing query strings in the cache key.

When the header and the console disagree

Each provider resolves the conflict at a different point in the pipeline, but the shape is the same: configuration applied at the edge outranks anything the origin says, and the origin has no way to detect that it lost.

Edge TTL precedence ladder Four ranked sources of freshness, from an edge console override down to max-age, all feeding a single effective edge TTL. The highest-ranked source present wins. Which signal actually sets the edge TTL 1. Edge-side override Cache Rule · beresp.ttl · MinTTL 2. CDN-Cache-Control Cloudflare and Fastly only 3. Surrogate-Control, s-maxage shared-cache directives 4. max-age last resort, also the browser TTL Effective edge TTL first source present wins origin is never told it lost Header and console disagree? The console wins, silently.

On Cloudflare the conflict is decided by the mode field: override_origin discards the header before the TTL is computed, and there is no response header telling you it happened. On Fastly the conflict is decided by execution order — the last write to beresp.ttl wins, so a snippet attached at a later position beats an earlier one regardless of intent. On CloudFront the conflict is arithmetic: the header is not discarded, it is clamped, which means a MinTTL floor produces caching where the origin asked for none.

Default TTLs for responses with no freshness

Error and redirect responses frequently carry no Cache-Control at all, and this is where the three providers diverge most sharply. A 404 that lives for one hour on one edge and ten seconds on another turns a single bad deploy into a very different incident.

Status Cloudflare (no directive) Fastly (no directive) CloudFront (no directive)
200, 206 120 minutes Service default TTL, 3600s on a new service DefaultTTL from the cache policy
301 120 minutes Service default TTL DefaultTTL
302, 303 20 minutes Service default TTL DefaultTTL
404, 410 3 minutes Service default TTL 10s error-caching minimum
403, 405, 414 Not cached Not cached by default 10s error-caching minimum
500, 502, 503, 504 Not cached Not cached by default 10s error-caching minimum

Two consequences follow. Fastly applies one blanket default to every cacheable status, so a 404 inherits the same hour a 200 gets unless you special-case it in vcl_fetch as shown above — this is the most common Fastly surprise for teams arriving from Cloudflare. CloudFront’s error caching is a separate configuration surface from the cache policy: the 10-second minimum lives in the distribution’s custom error responses, not in the policy, so setting MinTTL=0 does not change it. Always set the status-code TTLs explicitly on every provider rather than inheriting a default you did not choose.

Verification procedure

The goal is a measurement, not an impression. Run this sequence against one path and record the numbers.

  1. Force a cold fill. Purge the path or append a one-off cache buster so the first request provably reaches the origin.

    curl -sSD - -o /dev/null "https://assets.example.com/ttl-probe.json?cold=$(date +%s)" \
      | grep -iE 'cf-cache-status|x-cache|^age|cache-control'

    Expect cf-cache-status: MISS, x-cache: MISS, or x-cache: Miss from cloudfront, and either no Age header or age: 0.

  2. Request the same URL twice more, ten seconds apart. The second and third responses should report a hit and a climbing Age.

    URL="https://assets.example.com/ttl-probe.json"
    for i in 1 2 3; do
      curl -sSD - -o /dev/null "$URL" \
        | grep -iE 'cf-cache-status|^x-cache|^age' | tr '\n' ' '
      echo
      sleep 10
    done
    cf-cache-status: MISS age: 0
    cf-cache-status: HIT age: 10
    cf-cache-status: HIT age: 20
  3. Read the lifetime off the Age ceiling. Poll every thirty seconds and note the value of Age on the last response before the status flips back to MISS or EXPIRED. That number is the effective edge TTL — the one that matters, regardless of what the header claims.

    while :; do
      curl -sSD - -o /dev/null "$URL" | grep -iE 'cf-cache-status|^x-cache|^age' | tr '\n' ' '
      echo; sleep 30
    done
  4. Confirm the browser lifetime separately. The Cache-Control the client receives should still carry max-age=120, unchanged by the edge. If the edge rewrote it, a Cache Rule or VCL snippet is mutating the delivered header.

  5. Check for header leakage. Neither Surrogate-Control nor CDN-Cache-Control may appear in the client-visible response. Their presence means the edge did not process them, which usually means the request bypassed the edge entirely.

Reading the cache decision from response headers Three rows, one per CDN, listing the status header each emits, the values it can take, and the supplementary header that tells you which node answered. What each edge tells you in the response Cloudflare cf-cache-status HIT · MISS · EXPIRED · REVALIDATED DYNAMIC means never eligible age climbs to edge TTL Fastly x-cache HIT · MISS · MISS, HIT two values means shield then edge x-cache-hits counts reuse CloudFront x-cache Hit from cloudfront RefreshHit · Miss · Error x-amz-cf-pop names the PoP

Interpret the values precisely. Cloudflare’s REVALIDATED means the object aged out and the origin returned 304, which is a success for your origin-offload numbers even though it is not a HIT. Fastly’s comma-separated x-cache reports every node the request traversed, so MISS, HIT means the shield missed and the edge hit — invaluable when you are reasoning about tiered caching and origin shield. CloudFront’s RefreshHit from cloudfront is the equivalent of REVALIDATED: the object was stale, CloudFront revalidated, and the origin confirmed it was unchanged.

Migration checklist

Moving a property between these three is where TTL assumptions surface as incidents. Work through this list before you move the DNS record.

  1. Inventory the origin’s actual headers per route. Capture Cache-Control, Surrogate-Control, CDN-Cache-Control and Vary for a representative URL of every content class. This is the contract you are porting.
  2. Translate the override surface, not the header. A Cloudflare Cache Rule in override_origin mode has no CloudFront equivalent other than a MinTTL equal to MaxTTL; a Fastly set beresp.ttl snippet has no Cloudflare equivalent other than an override-mode rule. Write the mapping down before you build anything.
  3. Neutralize the clamps. On CloudFront set MinTTL=0 unless you deliberately want a floor, and set MaxTTL above your longest s-maxage. On Fastly confirm no snippet unconditionally assigns beresp.ttl.
  4. Port status-code TTLs explicitly. Do not let the new provider’s defaults for 404, 302 and 5xx decide for you; copy the table above into concrete configuration.
  5. Decide what replaces the grace window. If you are moving onto CloudFront, stale-while-revalidate disappears. Either accept the extra origin traffic, add an origin shield, or move the resilience to your origin’s own capacity.
  6. Re-express the purge model. Fastly surrogate keys, Cloudflare cache tags and CloudFront path invalidations have different granularity and different cost profiles; see using cache tags for surrogate-key purging for the mapping.
  7. Run the verification procedure on both providers in parallel before cutting DNS. Measure the Age ceiling on old and new for the same five URLs; any route where the two differ by more than a few percent is a misconfiguration, not a rounding difference.
  8. Lower the DNS TTL first. Cut the record’s TTL well ahead of the switch so a rollback takes minutes, not hours.

Troubleshooting

The edge caches a response the origin marked no-store

Almost always CloudFront with a non-zero MinTTL. Confirm the policy first, then the response:

aws cloudfront get-cache-policy --id "$POLICY_ID" \
  --query 'CachePolicy.CachePolicyConfig.{min:MinTTL,def:DefaultTTL,max:MaxTTL}'
curl -sSD - -o /dev/null "$URL" | grep -iE 'cache-control|^x-cache|^age'

If min is greater than zero, CloudFront is ignoring the opt-out directives by design. Set MinTTL to 0 and re-apply, then invalidate the affected paths — the already-cached copies keep their original lifetime and will not shorten on their own.

max-age is honored on one provider and ignored on another

Check the override surface, not the header. On Cloudflare, list the cache rules and look for "mode": "override_origin":

curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets/phases/http_request_cache_settings/entrypoint" \
  -H "Authorization: Bearer $CF_API_TOKEN" | grep -o '"mode":"[a-z_]*"'

On Fastly, search the generated VCL for unconditional TTL assignment. Any set beresp.ttl outside a conditional makes header tuning pointless:

fastly vcl custom list --service-id "$SERVICE_ID" --version latest

A 404 storm keeps hitting the origin

The provider’s default TTL for negative responses is too short, or the response is not cacheable at all. On Fastly the fix is the beresp.status == 404 branch shown earlier; on Cloudflare add a status_code_ttl entry; on CloudFront raise the error-caching minimum in the distribution’s custom error responses. Thirty seconds is usually enough to absorb a crawler without making a genuine fix feel slow.

The object expires far sooner than s-maxage says

Read Age on a hit. If the ceiling sits well below s-maxage, three causes dominate: a MaxTTL truncation on CloudFront, an eviction under memory pressure on a low-traffic path (the object was simply not popular enough to keep), or a request landing on a different PoP each time. The x-amz-cf-pop and Fastly x-served-by headers distinguish the last case immediately — if the PoP identifier changes on every request, you are measuring cold nodes, not a short TTL.

Browsers keep the old copy after you shorten the TTL

Edge TTL and browser TTL are separate clocks and only one of them has a purge API. If you shipped a long max-age and then shortened it, every client that already stored the response keeps the original lifetime until it expires. Ship a new URL instead — the discipline described in setting Cache-Control headers for static and dynamic content exists precisely to make this recoverable.

Frequently Asked Questions

Why does the same Cache-Control header produce different edge lifetimes on each CDN? Because each provider runs its own TTL calculator over a different set of inputs. Cloudflare and Fastly read CDN-Cache-Control and Surrogate-Control before they look at s-maxage, CloudFront reads neither and clamps whatever it finds to the cache policy’s min and max, and each has a different fallback for responses with no directive at all.

Does CloudFront really cache responses marked no-store? Yes, whenever the attached cache policy has a MinTTL greater than zero. CloudFront honors no-store, no-cache and private only when MinTTL is zero, so a floor set for hit-ratio reasons silently overrides the origin’s explicit opt-out. Set MinTTL=0 on any behavior that can serve private responses.

Which provider should I target with CDN-Cache-Control versus s-maxage? Send both. CDN-Cache-Control gives Cloudflare and Fastly a precise edge lifetime that browsers never see, and s-maxage is the fallback that CloudFront and any intermediate proxy will read. Keep max-age for the browser, and make sure the edge strips the CDN-only headers before delivery.

How do I tell whether the edge or my origin decided the TTL? Compare the Age ceiling you measure against the s-maxage the origin sends. If they match, the header decided; if the ceiling is shorter, look for a MaxTTL truncation or an override rule; if it is longer, look for a MinTTL floor or an unconditional beresp.ttl assignment. The response headers never report which side won, so measurement is the only reliable answer.

Is stale-while-revalidate portable across all three? No. Fastly honors it natively and also exposes beresp.stale_while_revalidate, Cloudflare expresses the equivalent through the serve-stale settings on a Cache Rule, and CloudFront ignores the directive entirely. Any migration onto CloudFront needs a deliberate replacement for the grace window, usually an origin shield plus extra origin headroom.

Back to Cache-Control & CDN TTL