Fastly Surrogate Keys for Fingerprinted Assets

You have a release in production with dozens of fingerprinted assets — JS bundles, CSS, fonts, images — and you need to purge the entire set simultaneously. URL-by-URL purges work, but require a separate API call for each object and the latency stacks up. Fastly surrogate keys let you tag every asset in a release with a shared label, then invalidate the entire group in a single API call that propagates to all points of presence in under 150 ms. This guide explains how to set those keys, how to group them by release, and when to reach for a URL purge instead.

Diagnosis: What Surrogate-Key Header Is Your Origin Actually Sending?

Before writing VCL, confirm whether your origin is currently emitting any surrogate-key headers.

curl -sI "https://example.com/assets/app.a1b2c3d4.js" \
  | grep -Ei "^(surrogate-key|surrogate-control|cache-control|age):"
Response Meaning Action
Surrogate-Key: release-v2-5-0 static-assets Origin is setting keys correctly Skip to purge API section
No Surrogate-Key header Origin is silent; keys must be set in VCL vcl_deliver Add VCL snippet below
Surrogate-Control: max-age=... present but no Surrogate-Key Caching is configured but tagging is not Add key header alongside existing directive
Cache-Control: no-store Origin is opting out of Fastly caching entirely Fix Cache-Control first; surrogate keys have no effect on uncacheable responses

Fastly strips the Surrogate-Key header before forwarding responses to browsers, so the header is safe to set on every asset response — clients never see it.

Concept Clarification: Surrogate Keys vs URL Purge

A URL purge targets one object in Fastly’s cache: the key is the full URL, including scheme, host, and path. For a deployment that changes fifty assets, that means fifty sequential or batched API calls, each purging one entry.

Surrogate keys invert the relationship. Instead of one call per object, you associate each object with one or more string labels in the Surrogate-Key response header. Fastly maintains an internal reverse index: tag → set of cached objects. A single purge-by-key call then invalidates every object in that set across every POP simultaneously.

For fingerprinted assets the practical effect is: tag every file produced by a release with release-v2-5-0, and a single POST /service/$SERVICE_ID/purge/release-v2-5-0 clears the entire release from all edges in one round-trip.

The Surrogate-Key header accepts multiple space-separated tokens. A single asset can belong to many tag groups at once:

Surrogate-Key: release-v2-5-0 static-assets app-bundle

This lets you purge by release (release-v2-5-0), by asset class (static-assets), or by component (app-bundle) — whichever granularity the incident demands.

Decision Matrix: URL Purge vs Surrogate-Key Purge

Criterion URL Purge Surrogate-Key Purge
Objects invalidated per API call 1 Unlimited (entire tag group)
Required header on origin response None Surrogate-Key: tag [tag ...]
Propagation time across all POPs < 150 ms < 150 ms (identical)
API endpoint POST /service/$ID/purge/$url_encoded_url POST /service/$ID/purge/$key
Suitable for single-file hotfix Yes — precise, no collateral Overkill; use URL purge
Suitable for full-release rollout Requires a script looping over all URLs Single call covers all tagged objects
Rollback of a partial bad deploy Hard — must know every affected URL Tag components separately; purge only the affected component key
CDN setup required None Surrogate-Key header on every cacheable response
Audit / traceability Easy — exact URL in logs Need to know which URLs were tagged with the key

The decision rule is straightforward: if you deploy more than one asset at a time, surrogate keys are the operationally correct approach. URL purge remains useful for emergency hotfixes to a single file or when a third-party origin does not support response header modification.

Diagram: URL Purge vs Surrogate-Key Purge

URL purge vs surrogate-key purge Left side shows URL purge requiring a separate API call per cached object; right side shows surrogate-key purge where one API call invalidates all tagged objects simultaneously across all Fastly POPs. URL Purge one call per object Surrogate-Key Purge one call, all tagged objects Deploy script POST /purge/url1 app.a1b2c3d4.js purge call #1 vendor.b3c4d5e6.js purge call #2 styles.c4d5e6f7.css purge call #3 logo.d5e6f7a8.svg purge call #4 … N calls × latency Deploy script POST /purge/release-v2-5-0 Fastly tag index release-v2-5-0 → {4 objects} app.a1b2c3d4.js ✓ vendor.b3c4d5e6.js ✓ styles.c4d5e6f7.css ✓ logo.d5e6f7a8.svg ✓ 1 call, all POPs, < 150 ms all objects purged simultaneously
URL purge (left) requires a separate API call for each cached object; surrogate-key purge (right) invalidates every tagged object in one call across all Fastly POPs.

Setting Surrogate Keys

Add a custom VCL snippet in the Fastly console under Service → Custom VCL or via the API. The vcl_deliver subroutine runs just before Fastly sends the response to the client, making it the right place to attach metadata headers.

sub vcl_deliver {
  #FASTLY deliver

  # Tag every fingerprinted asset with the release key and asset-class keys.
  # Fastly strips Surrogate-Key before forwarding to browsers.
  if (req.url ~ "^/assets/") {
    set resp.http.Surrogate-Key = "release-v2-5-0 static-assets";

    # Refine by component so partial purges are possible.
    if (req.url ~ "\.(js)(\?|$)") {
      if (req.url ~ "vendor\.[a-f0-9]{8}") {
        set resp.http.Surrogate-Key = "release-v2-5-0 static-assets vendor-bundle";
      } else {
        set resp.http.Surrogate-Key = "release-v2-5-0 static-assets app-bundle";
      }
    }

    if (req.url ~ "\.(css)(\?|$)") {
      set resp.http.Surrogate-Key = "release-v2-5-0 static-assets css-bundle";
    }
  }
}

The release tag (release-v2-5-0) must be updated on every deploy. If you generate VCL programmatically from a CI/CD pipeline, template this value from $RELEASE_TAG at build time so it always reflects the active release.

Option 2: Set the Header at the Origin

For origins under your control, setting Surrogate-Key directly in the application is cleaner than VCL because the tag logic lives alongside the code that produces the assets. Fastly honours the header regardless of where it was added.

Example in an Express/Node.js origin serving asset files:

import express from "express";
import path from "path";
import fs from "fs";

const app = express();
const RELEASE_TAG = process.env.RELEASE_TAG || "release-unknown";

// Serve fingerprinted assets from the /assets directory.
// Surrogate-Key allows Fastly to purge the entire release in one call.
app.use("/assets", (req, res, next) => {
  const url = req.path;
  const tags = [RELEASE_TAG, "static-assets"];

  if (/vendor\.[a-f0-9]{8,}\.js$/.test(url)) tags.push("vendor-bundle");
  else if (/\.[a-f0-9]{8,}\.js$/.test(url)) tags.push("app-bundle");
  else if (/\.[a-f0-9]{8,}\.css$/.test(url)) tags.push("css-bundle");

  res.setHeader("Surrogate-Key", tags.join(" "));
  res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
  next();
});

app.use("/assets", express.static(path.join(import.meta.dirname, "dist/assets")));

app.listen(3000);

Hash length in these patterns defaults to {8,} — eight hex characters. For monorepos producing thousands of chunks, use 12–16 characters to reduce collision probability.

Purging a Release by Surrogate Key

With the header in place, purge the entire release immediately after a deploy completes:

# Purge every object tagged release-v2-5-0 across all Fastly POPs.
# Replace SERVICE_ID and FASTLY_TOKEN with your credentials.
curl -X POST "https://api.fastly.com/service/$SERVICE_ID/purge/release-v2-5-0" \
  -H "Fastly-Key: $FASTLY_TOKEN" \
  -H "Accept: application/json"

A successful purge returns HTTP 200 with a JSON body:

{ "status": "ok" }

To purge a specific component without touching the rest of the release — for example, after a hotfix to the vendor bundle only:

curl -X POST "https://api.fastly.com/service/$SERVICE_ID/purge/vendor-bundle" \
  -H "Fastly-Key: $FASTLY_TOKEN" \
  -H "Accept: application/json"

Both calls complete in under 150 ms. Fastly’s purge infrastructure propagates the invalidation signal to all POPs synchronously before returning the response, so there is no polling required.

What about HTML entry points?

HTML files that reference your fingerprinted assets should carry a shorter TTL and must also be purged on deploy — either by URL or by a separate surrogate key such as html-entry-points. Fingerprinted asset URLs are immutable by definition (a content change produces a new URL), so the purge on the assets themselves is only necessary when you need to clear stale objects that accumulated under the previous release key.

Verification

After the purge call returns, confirm the invalidation reached the edge before your smoke tests run:

# Fetch an asset that was just purged.
# Expect X-Cache: MISS on the first request (Fastly is re-fetching from origin).
# A subsequent request within seconds should return X-Cache: HIT.
curl -sI "https://example.com/assets/app.a1b2c3d4.js" \
  | grep -Ei "^(x-cache|surrogate-key|age|cache-control):"

Fastly strips Surrogate-Key from the downstream response, so you will not see it in this output — its absence confirms Fastly received and processed it correctly. What you should see is Age: 0 on the first response after a purge, confirming a fresh origin fetch.

Can I verify which keys are assigned to a cached object?

Not via a standard response header (Fastly strips it). Use the Fastly real-time log streaming endpoint or the DEBUG-AUTH header in testing environments to inspect surrogate key assignments before they are stripped.

Partial Release Purge Pattern

A full release purge removes all tagged objects at once. In large teams where different squads own different bundles, you often want to purge only the broken component without invalidating unaffected assets.

Structure your tags as a two-level hierarchy:

Tag Objects covered
release-v2-5-0 Every asset in the release (full purge)
app-bundle Main application JS chunks only
vendor-bundle Third-party dependency bundle only
css-bundle All CSS files
static-assets All asset types (superset, rarely purged directly)

Each asset receives multiple tags. app.a1b2c3d4.js might carry release-v2-5-0 static-assets app-bundle. A bug in the vendor bundle triggers purge/vendor-bundle only — the application bundle and CSS remain cached and continue serving without any disruption.

This pattern maps well to CI/CD pipelines where separate jobs build and deploy each component. Each job emits its component tag; the orchestrator emits the release tag.

Comparison With Cloudflare Cache Tags

Fastly’s Surrogate-Key mechanism is conceptually identical to Cloudflare’s Cache-Tag purge. The differences are operational:

Feature Fastly Surrogate-Key Cloudflare Cache-Tag
Response header name Surrogate-Key Cache-Tag
Purge endpoint POST /service/$ID/purge/$key POST /zones/$ZONE/purge_cache with {"tags":["tag"]}
Propagation time < 150 ms Typically < 30 s (varies by plan)
Multiple tags per object Space-separated values in one header Comma-separated values in one header
Soft purge (stale-while-revalidate) Supported via Fastly-Soft-Purge: 1 header Not supported for cache-tag purge
Free tier Available Business plan or above

If you operate on both CDNs and need consistent purge behaviour, keep the logic in the origin layer: set both Surrogate-Key and Cache-Tag headers from the same response path, using the same tag naming convention.

When to Reconsider

Surrogate keys are the right default when managing multiple assets per release, but URL purge is the correct tool when:

  • Hotfixing a single file. A one-line change to one asset does not justify the overhead of tagging infrastructure. Purge the specific URL and re-deploy that file.
  • The origin cannot set headers and VCL is unavailable. If you are using Fastly as a pass-through with no custom VCL (e.g., shield-only configuration), you cannot inject surrogate keys without code changes.
  • Assets are truly immutable. If you follow strict content-hashing with a 12-month Cache-Control: max-age=31536000, immutable, you do not need to purge old assets at all — they will age out, and new deploys produce new URLs. Surrogate-key purges on immutable assets only matter when you need to free cache space immediately or are rolling back a bad deploy.
  • Release tags are not updated per deploy. If the VCL tag value is static and never changes, a surrogate-key purge on release-v2-5-0 will continue to match that key on every future deploy that forgets to update it. Stale tags are harder to debug than stale URLs.

For rollback scenarios specifically — where you need to re-serve a previous release after a bad deploy — see the Fastly rollback guide, which covers re-purging the new release key and reinstating the previous one.

Does the Surrogate-Key header affect browser caching?

No. Fastly removes Surrogate-Key before forwarding the response downstream. Browsers only see Cache-Control, ETag, and other standard headers. The surrogate key is a CDN-internal mechanism.

How many tags can one object carry?

Fastly supports up to 16 KB of surrogate key data per response (approximately 1,000–2,000 short tags). In practice, three to five tags per asset is sufficient: one release tag, one asset-class tag, and one component tag.

Does a surrogate-key purge also clear browser caches?

No. Surrogate-key purge clears Fastly’s edge cache. Browsers that have already cached the asset locally will continue serving it until the browser TTL expires or the user hard-reloads. This is why fingerprinting in HTTP headers combined with long max-age values remains the correct approach for assets — the URL change, not a purge, is what causes browsers to fetch fresh content.