Fastly guide

Fastly VCL Caching: A Safe Production Tutorial

Treat cache behavior as a release: define the response contract, make the smallest VCL change, activate it deliberately, and prove the resulting behavior before expanding scope.

Published
Updated
Reading time
19 min read
On this page

Fastly can make a public response fast and inexpensive to serve, but an incorrect cache key or TTL can expose one user's representation to another or preserve stale content longer than the product allows. This tutorial uses a narrow, observable rollout for DevOps and web engineers operating a Fastly VCL service. The same discipline matters even more once a route sits behind a managed, multi-provider edge: an orchestration layer that adds Fastly VCL caching to a stack it also observes and secures needs the cache key, TTL, and pass decisions on every route to be provable on demand, not just correct on the day they shipped.

Overview

Outcome

Cache Acme Shop's anonymous product images for one hour, prove the first request is a MISS and the next equivalent request is a HIT, and keep account and checkout traffic on PASS.

Do not assume every Cache-Control directive has its usual effect

Fastly documents caching semantics that diverge from a generic interpretation of HTTP Cache-Control. In particular, its cache-control guide lists only public, private, max-age, and s-maxage as directives that influence Fastly caching; directives such as no-cache, no-store, and must-revalidate are passed downstream but do not, by themselves, control Fastly caching. Read Fastly's current documentation before relying on a header policy.

Prerequisites

Before changing production traffic, have all of the following:

  • Access that can create and activate a new version of the correct Fastly service. Confirm its service ID, domains, and the team that owns the origin.
  • A known-good active version and a named person authorized to reactivate it. Fastly service configurations are versioned; activation is a separate operational action from editing.
  • An origin test URL or a low-risk public object whose response is stable, non-personalized, and safe to cache.
  • A way to compare origin load, error rate, cache status, and a representative user journey before and after the change.
  • An origin contract for the route: allowed methods, authentication behavior, cookies, query parameters, content variants, invalidation owner, and maximum acceptable staleness.

Do not start with login, checkout, account, search results, authorization-dependent APIs, or any response that sets or reflects user state. A cache improvement that changes correctness is not an improvement.

Acme Shop scenario

Acme Shop serves public catalog images at https://shop.example.test/assets/products/red-mug.webp. The origin emits a stable image, and product releases own invalidation. Its /account and /checkout routes use sessions and must never use the shared cache. Start with this one image route, not the whole catalog.

Acme Shop cache decision
  1. Request

    Browser requests a public product image.

  2. Safety gate

    Identity-bearing requests and every route outside the public namespace use PASS.

  3. Cache

    Eligible anonymous requests look up a HIT or fetch a MISS.

  4. Origin

    Only a response safe for shared storage receives the explicit TTL.

Only an anonymous request for the public image namespace reaches cache lookup. Credentialed or cookie-bearing requests take PASS directly to origin.

1. Map the service and backend activation path

In Fastly, a service contains the delivery configuration. A backend describes where Fastly fetches from when it needs the origin. Make the relationship explicit before writing VCL:

  1. Identify the service version currently serving the hostname and preserve its version number as the immediate rollback target.
  2. Confirm which backend serves the test route, including its address, host-header expectations, health behavior, TLS settings, timeouts, and any existing shielding configuration.
  3. Clone or otherwise create an editable service version according to the Fastly service versioning guidance. Do not edit a production configuration without knowing the version you will activate and the version you will restore.
  4. Add the backend or VCL change only to the new version, review the generated diff with the origin owner, then activate that version in a planned window. Fastly's backend guide covers backend configuration concepts and behavior.

Activation changes live traffic. Creating a draft version does not. Keep a short change record containing the service ID, old and candidate versions, routes affected, expected cache status, validation request, and rollback version.

2. Define the effective cache contract first

Write the intended policy in plain language before translating it into headers or VCL. For example: “GET /assets/* is public, varies only by normalized path, stays fresh at Fastly for one hour, may stay stale for five minutes while revalidating, and must be purgeable by release.”

Fastly determines freshness from origin response headers in documented precedence: Surrogate-Control, then Cache-Control: s-maxage, then Cache-Control: max-age, then Expires. Surrogate-Control is useful when browsers need a shorter policy than Fastly. For the exact precedence and documented divergences from RFC 9111, see About cache control headers and Cache freshness.

VCL may override the edge lifetime. In vcl_fetch, beresp.ttl controls Fastly's cached-object TTL; rewriting beresp.http.Cache-Control there does not retroactively change the TTL Fastly already calculated. Conversely, changing only beresp.ttl does not set browser behavior. Keep edge and browser policies intentionally separate.

Do not rely on Fastly's fallback behavior as your policy. Fastly documents a default TTL of 3600 seconds (one hour) for an otherwise-cacheable response that carries none of Surrogate-Control, Cache-Control, or Expires; a custom-VCL service that reaches vcl_fetch without any freshness header present can instead see beresp.ttl arrive at a shorter fallback before your own logic runs. Either way, a response that "just happens" to be cacheable for an implicit duration is not a contract anyone reviewed. Write the explicit header, or the explicit set beresp.ttl, and treat an object that only survives on a fallback default as a bug to fix, not a behavior to depend on.

Steps

Full request lifecycle

This complete, deliberately narrow lifecycle is for Acme Shop's public asset route. Add it to a reviewed custom-VCL configuration in the correct placement for the service; do not replace generated Fastly logic blindly.

sub vcl_recv {
  if (req.method != "GET" && req.method != "HEAD") {
    return (pass);
  }

  # The first rollout admits only a known public, versioned image namespace.
  if (req.url !~ "^/assets/products/") {
    return (pass);
  }

  # Any identity or cookie state makes the request ineligible for shared cache.
  if (req.http.Authorization || req.http.Cookie) {
    return (pass);
  }

  return (lookup);
}

sub vcl_fetch {
  if (bereq.url !~ "^/assets/products/") {
    return (pass);
  }

  # Fastly does not use these directives when it derives beresp.ttl by default.
  # Evaluate them, plus Set-Cookie, before setting any explicit edge TTL.
  if (beresp.http.Set-Cookie ||
      beresp.http.Cache-Control ~ "(?i)(private|no-store)" ||
      beresp.http.Surrogate-Control ~ "(?i)(private|no-store)") {
    return (pass);
  }

  set beresp.ttl = 1h;
  set beresp.stale_while_revalidate = 5m;
  return (deliver);
}

This example is a starting pattern, not a copy-and-paste policy. The response checks must precede beresp.ttl: Fastly's default TTL parsing does not itself honor private or no-store, and a Set-Cookie response must not enter a shared object. Set stale behavior only when the content owner accepts serving an older representation. Preserve or deliberately set browser-facing headers separately, and test the response produced by your actual origin.

3. Choose the VCL cache key and TTL together

Fastly's default cache key is built in vcl_hash from req.url and req.http.host; every service also folds in req.vcl.generation so that a service-wide purge-all continues to invalidate objects correctly. Getting the VCL cache key and TTL right together matters because they trade off against each other: a wider key with a long TTL fragments the cache into many rarely reused objects, while a narrow key with a long TTL is exactly how one user's response ends up served to another. Change the default key only when the application contract requires it, and re-check the TTL decision whenever the key changes.

For every candidate route, document these questions:

  • Which path normalization is safe? Do not collapse paths if the origin distinguishes them.
  • Which query parameters alter the representation? Remove known tracking parameters only after proving they do not affect the response.
  • Does locale, device format, experiment assignment, or a request header change the response? Prefer a controlled Vary policy where appropriate instead of an unreviewed custom key.
  • Can cookies, Authorization, a session header, or an account identifier influence the body? If yes, do not place that response in a shared public cache without a deliberately isolated key and security review.

Avoid using a raw cookie or authorization value in a shared key. It fragments the cache, risks sensitive data in operational surfaces, and can still be wrong if other identity inputs are omitted. Fastly's caching best practices discusses default key behavior and the tradeoff between fragmentation and unsafe coalescing.

When a genuine key change is justified, make it in vcl_hash explicitly rather than reaching for a broader Vary policy first. For Acme Shop, suppose a second image format needs its own object because the origin cannot vary on Accept reliably:

sub vcl_hash {
  set req.hash += req.url;
  set req.hash += req.http.host;

  # Extend the default key only for the one header this route needs.
  if (req.url ~ "^/assets/products/" && req.http.Accept-Format) {
    set req.hash += req.http.Accept-Format;
  }

  # Required so a purge-all still invalidates every generation of this key.
  set req.hash += req.vcl.generation;
  return (hash);
}

Two consequences follow directly from Fastly's manipulating the cache key guidance and the req.hash reference: first, every value you add multiplies the number of distinct objects Fastly must fill and purge, so a surrogate-key or per-URL purge must now account for each added dimension; second, omitting req.vcl.generation from a custom vcl_hash silently breaks purge-all for that route, because the generation marker is what ties a purge-all operation to every hashed object. Test a custom key change with an explicit purge, not only with a cache-hit check.

4. Understand Fastly pass behavior before you rely on it

return(pass) is a correctness control for requests or responses that must not be served from the normal cache path, and Fastly's pass behavior differs meaningfully depending on where it happens. A request pass in vcl_recv skips lookup and request collapsing outright. A response pass in vcl_fetch occurs after a lookup and origin fetch; Fastly documents that a cacheable response reached this way creates a hit-for-pass object rather than simply not caching anything.

Fastly request and response pass take different paths

Use request pass for known-ineligible traffic; treat response pass as a cache-path outcome with a hit-for-pass effect to measure.

Download:PNGSVG
sub vcl_recv {
  if (req.method != "GET" && req.method != "HEAD") {
    return (pass);
  }

  if (req.http.Authorization || req.http.Cookie) {
    return (pass);
  }
}

The route and cookie names above are examples. Do not add broad cookie passes without measuring the loss of cacheability, and do not remove them until the response contract proves the object is public.

The hit-for-pass object matters operationally, not just semantically. Fastly's request collapsing documentation explains that while a hit-for-pass object exists, every request for that key is sent straight to the origin as if it had passed in vcl_recv, deliberately disabling request collapsing for that key. Its TTL defaults to about two minutes and is otherwise bounded by whatever beresp.ttl you set before returning pass from vcl_fetch; once it expires, the next request is a normal miss and collapsing resumes. A route that passes intermittently because of an inconsistent origin header can therefore see origin load spike right after each hit-for-pass object expires, which looks like a caching regression but is really a response-contract inconsistency. For the full request-versus-response distinction, including hit-for-pass, consult Fastly VCL best practices.

If you need to force a one-time refetch while retaining normal caching behavior, Fastly documents req.hash_always_miss as different from return(pass). Use it only in a narrowly scoped, temporary test and remove it after validation.

5. Handle origin failures without breaking Fastly VCL caching

A correct cache key and TTL are not enough if the origin becomes slow or unhealthy: the same Fastly VCL caching decisions that keep Acme Shop's product images fast during normal operation should also decide what happens when the origin cannot answer at all. Fastly separates two related but distinct behaviors, and conflating them is a common source of surprise:

  • stale-while-revalidate serves a recently expired object immediately while Fastly revalidates in the background, bounding perceived latency without ever exposing the client to origin failure.
  • stale-if-error (and the equivalent beresp.grace) keeps a stale object eligible to be served specifically when the origin returns an error or cannot be reached, which is a resilience control rather than a latency control.
sub vcl_fetch {
  if (bereq.url !~ "^/assets/products/") {
    return (pass);
  }

  if (beresp.http.Set-Cookie ||
      beresp.http.Cache-Control ~ "(?i)(private|no-store)" ||
      beresp.http.Surrogate-Control ~ "(?i)(private|no-store)") {
    return (pass);
  }

  set beresp.ttl = 1h;
  set beresp.stale_while_revalidate = 5m;
  set beresp.stale_if_error = 1d;

  # A sick origin should not turn a cached product image into a 5xx.
  if (beresp.status >= 500 && beresp.status < 600 && stale.exists) {
    return (deliver_stale);
  }

  return (deliver);
}

If VCL sets beresp.grace, Fastly documents that it overrides any stale-if-error value coming from the origin's own Cache-Control or Surrogate-Control headers, so pick one source of truth for that duration and do not set both expecting them to add together. Decide stale_if_error's duration deliberately: for Acme Shop's product images, serving a day-old image during an origin outage is an acceptable tradeoff the catalog team can own; for a price or inventory response, the same setting could serve an incorrect number and needs a much shorter window or none at all. See Fastly's serving stale content guide for the full deliver_stale contract in vcl_fetch and vcl_error.

6. Add shielding only after the base path is correct

Shielding sends edge misses through a chosen Fastly shield POP before the origin. It can protect the origin and improve reuse across edge POPs, but it changes the request path and can cause VCL to run more than once. Add it after the unshielded cache behavior is understood, not in the same first change.

When evaluating shielding:

  • Pick and test a shield location that is appropriate for the origin and its network path.
  • Verify origin access controls permit the shielded request path and origin logs retain a usable request correlation ID.
  • Make response-header mutations safe for double execution. Fastly recommends distinguishing the client-connected POP when applying client-facing response changes.
  • Interpret cache diagnostics in the shielding context: an edge miss followed by a shield hit avoids an origin request, while headline cache-hit calculations may include both events.

Read Shielding before enabling it. It documents double execution, cache-status interpretation, and the risk that response changes made at the wrong point can be cached downstream.

7. Test, activate, and validate in stages

Use a low-risk route and a candidate service version first. Do not make a broad cache-key change and a broad TTL change in the same release.

  1. Capture a baseline response from the test URL, including status, body hash, Cache-Control, Age, X-Cache, X-Cache-Hits, X-Served-By, and origin request count.
  2. Exercise expected variants: a plain anonymous request, each intended locale or representation, a request with a tracking parameter, and an authenticated or cookie-bearing request that must pass.
  3. Activate the candidate version only after the expected results are written down. Send a small number of controlled requests, then compare body hashes and headers with the baseline.
  4. Confirm the expected sequence: a cold object can miss, a repeated equivalent request should become a hit within its TTL, and a request that must pass should not reuse the public object. Check the application journey as well as headers.
  5. If shielding is enabled, validate both edge and shield diagnostics and confirm that the origin sees fewer equivalent requests rather than simply different cache headers.
  6. Expand traffic or routes gradually, watch cache-hit ratio alongside origin request rate, latency, errors, and business conversions, then document the observed behavior.

Fastly's Checking cache guide explains curl-based checks and the debugging headers. Treat Fastly-Debug as diagnostic access: it can expose information normally removed from responses, so use it only from authorized tooling and do not expose it to clients.

Validation

Validation

Run these only against Acme Shop's staging hostname or an approved production test object. Compare the body checksum as well as headers.

curl -sS -D - -o /dev/null https://shop.example.test/assets/products/red-mug.webp
curl -sS -D - -o /dev/null https://shop.example.test/assets/products/red-mug.webp
curl -sS -D - -o /dev/null -H 'Cookie: session=demo' https://shop.example.test/account
Representative output: positive cache reuse

HTTP/2 200 Cache-Control: public, max-age=300 Age: 9 X-Cache: HIT

  • Positive: the repeated anonymous asset request returns the same checksum and becomes a HIT within the one-hour edge TTL.
  • Negative: /account with the synthetic session cookie does not reuse the asset object and follows PASS behavior.
  • Failure: a changed checksum, an account response with shared-cache headers, unexpected 5xx, or origin load above the stop threshold means stop rollout and reactivate the prior version.

Make cache evidence portable, not just correct

The header and checksum comparisons above are the raw evidence for one Fastly service on one day. Once a route sits behind more than one edge provider, or Fastly configuration itself changes hands over time, that evidence needs to be captured the same way every time so a HIT, MISS, or PASS on Tuesday means the same thing it meant last month, and the same thing it would mean on a different provider. Optimi's MYO layer exists for exactly this: it standardizes cache-behavior evidence across every provider in the stack, so a Fastly VCL caching rollout is audited with the same baseline-and-compare discipline as everything else at the edge, not a one-off script someone remembers to run.

Rollback

Rollback should be faster than diagnosis for a suspected cache leak, stale critical content, or origin regression:

  1. Stop expanding the rollout and record timestamps, request IDs, affected URL patterns, and the candidate service version.
  2. Reactivate the known-good service version. Verify the active version, then rerun the same controlled requests.
  3. If incorrect objects may remain cached, perform a targeted purge only after confirming its scope and ownership. Purging does not fix an unsafe key by itself; restore safe behavior first.
  4. Confirm anonymous and authenticated journeys, origin load, error rate, and cache diagnostics have returned to the expected state.
  5. Preserve the evidence and correct the candidate version offline. Do not re-enable it based only on a single cache hit or miss.

Troubleshooting

SymptomLikely causeCheckSafe correction
Repeated public asset remains MISSCookie, response Set-Cookie, or an uncacheable origin responseCompare request and origin response headersKeep the route on PASS until the response contract is public and stable.
Account content appears on an anonymous requestIdentity input is missing from the cache decisionCompare body checksums with and without a synthetic sessionReactivate the prior version, purge the affected public route only after restoring safety.
HIT serves an old image after releaseInvalidation and TTL contract disagreeCheck release time, Age, and purge recordPurge the approved surrogate key or object; do not shorten all TTLs as an emergency fix.
Origin load rises after rolloutA broad PASS removed request collapsing or fragmented the keyCompare pass rate, key dimensions, and origin requestsNarrow the pass condition or restore the previous version.
Origin requests spike right after quiet periods on a passed routeThe hit-for-pass object expired and every queued request refetched at onceCompare request timing against the hit-for-pass TTL and beresp.ttl set at passSet an explicit, sensible beresp.ttl when returning pass in vcl_fetch, or fix the response inconsistency causing the pass.
A purge-all does not clear objects from a route with a custom cache keyreq.vcl.generation was left out of a modified vcl_hashReview the vcl_hash diff for the change that introduced the custom keyAdd set req.hash += req.vcl.generation; back and redeploy before relying on purge-all again.
Origin outage serves stale content far longer than expectedberesp.grace was set alongside stale_if_error, and grace overrode the intended windowCompare the active beresp.grace value with the stale_if_error value in VCL and origin headersPick one control for the stale-on-error window and remove the other; do not set both expecting them to combine.

Common pitfalls

  • Assuming no-cache or no-store means Fastly will not cache: Fastly documents different semantics. Use its documented controls and test the effective result.
  • Changing a response header and assuming the edge TTL changed: In VCL, use beresp.ttl for Fastly's TTL; browser headers are a separate policy.
  • Caching a response that varies on identity: A missing key dimension can disclose data. Pass first; design a safe cache contract later.
  • Adding every query parameter, cookie, or header to the key: This converts one reusable object into many misses and can overload the origin.
  • Using pass everywhere to make a symptom disappear: It can remove request collapsing and hide the underlying response-contract error while increasing origin traffic.
  • Enabling shielding with client-facing response mutations: The service may execute twice, and a mutation at the shield can be stored by edge POPs.
  • Validating only X-Cache: A hit proves neither the body nor the variant is correct. Compare content, headers, application behavior, and origin telemetry.
  • Setting beresp.grace and stale_if_error and expecting them to stack: Fastly documents that a VCL grace statement overrides stale-if-error from response headers; pick one source of truth.
  • Forgetting req.vcl.generation after customizing vcl_hash: This silently breaks purge-all for that route long before anyone notices a stale object.

Primary Fastly references

Make Fastly VCL caching safer to operate at scale

Optimi's edge orchestration layer helps validate cache keys, TTLs, and pass behavior, plus origin protection and rollout telemetry, across Fastly and every other provider in your stack, so Performance gains never cost you Security or Visibility.

Discuss Fastly delivery