Using Cache Tags for Surrogate-Key Purging

After working through this guide you will be able to label every cacheable response with the entities it renders, keep that label set inside the header limits your CDN enforces, and fire a tag purge from your application’s write path so that changing one product invalidates exactly the pages that embed it — not the whole zone, and not a hand-maintained list of URLs.

Single-URL purging breaks down the moment a piece of data appears in more than one place. A price change on product 8842 touches the product page, two collection listings, the search index snippet, a JSON endpoint the storefront calls on hydration, and the sitemap. Enumerating those URLs from inside the code that writes to the products table is a coupling nobody maintains for long. Cache tags invert the relationship: the response declares what it depends on at render time, the edge remembers the association, and the writer only has to name the entity that changed. This guide builds that loop end to end, on top of the scopes and trade-offs described in the cache purging and invalidation parent guide.

Key implementation objectives:

  • Design a tag vocabulary keyed on data ownership (product:8842, tenant:17) rather than on page layout.
  • Emit Cache-Tag or Surrogate-Key from origin on every cacheable response, and keep the header under the platform’s byte ceiling.
  • Trigger the purge from a database hook or outbox queue so writers never need to know which URLs render the entity.
  • Make each purge call idempotent, batched, and retried, so a bulk import cannot turn into a purge storm.
One tag, many cached objects A single tag named product colon 8842 is associated at the edge with four different cached responses — a product page, a collection listing, a JSON API response, and a sitemap — so purging that one tag invalidates all four. Purge one tag, invalidate every object that carries it product:8842 one purge call /products/running-shoe-x HTML, edge TTL 1h /collections/running listing embeds price /api/v2/products/8842 JSON, edge TTL 5m /sitemap-products.xml regenerated nightly the writer names only the entity associations are recorded at response time, not purge time

Prerequisites and environment setup

You need three things before any of this works: an origin you can add response headers to, a CDN plan that supports tag purging, and an API token scoped to purge only.

Tag purging is a paid capability on some platforms. On Cloudflare, the Cache-Tag response header and purge-by-tag are Enterprise features; lower plans get purge-by-URL and purge-everything only. On Fastly, Surrogate-Key works on every plan and has no per-purge cost, which is why Fastly deployments tend to lean on tags far harder. Akamai exposes the same idea through Edge-Cache-Tag and Fast Purge by tag. Amazon CloudFront has no tag concept at all and needs the workaround described in step 4.

Check your toolchain and credentials first:

curl --version
jq --version

# Cloudflare: token needs Zone → Cache Purge → Purge
curl -s "https://api.cloudflare.com/client/v4/user/tokens/verify" \
  -H "Authorization: Bearer $CF_API_TOKEN" | jq '.result.status'
# "active"

# Fastly: token needs the purge_select scope on the service
curl -s "https://api.fastly.com/current_user" \
  -H "Fastly-Key: $FASTLY_TOKEN" | jq '.login'

Keep the purge token separate from your deploy token. A credential that can only invalidate cache is safe to hand to an application process that runs on every write; a broader zone-edit token is not.

Step 1 — Design the tag vocabulary before you emit anything

Tags are cheap to add and expensive to change, because retagging only takes effect as objects are re-fetched. Spend ten minutes on the naming scheme.

Use a namespace:identifier shape so tags are greppable and so a mistake in one namespace cannot collide with another. Four namespaces cover almost every application:

product:8842            # one row in one table — the workhorse tag
collection:running      # a set whose membership changed
template:pdp-v3         # every page rendered by this template
tenant:17               # everything belonging to one customer in a multi-tenant app

Two rules keep this healthy. First, tag by what the response reads, not by where it sits in the site — a collection listing that embeds twelve products carries twelve product: tags plus its own collection: tag, because any of those thirteen writes should invalidate it. Second, never emit a tag whose cardinality is one for the whole site. A tag like site or html looks convenient right up to the moment someone purges it and every object in the zone becomes a miss simultaneously; that is a purge-everything with extra steps, and the origin stampede it causes is the failure mode covered under customizing cache keys to improve hit ratio.

Step 2 — Emit the header from origin

The rendering layer is the only place that knows what a response read. Collect tags as you render, then set the header once on the way out. In Express:

// Collect dependencies during render, emit one header at the end.
function tagCollector(req, res, next) {
  const tags = new Set();
  res.locals.tag = (t) => tags.add(t);
  res.on("pipe", () => {});          // no-op, keeps ordering explicit
  const end = res.end.bind(res);
  res.end = (...args) => {
    if (tags.size && !res.headersSent) {
      const list = capTags([...tags]);         // see step 3
      res.setHeader("Cache-Tag", list.join(",")); // Cloudflare / Akamai
      res.setHeader("Surrogate-Key", list.join(" ")); // Fastly (space separated)
    }
    return end(...args);
  };
  next();
}

app.get("/products/:slug", tagCollector, async (req, res) => {
  const product = await db.productBySlug(req.params.slug);
  res.locals.tag(`product:${product.id}`);
  res.locals.tag(`collection:${product.collection}`);
  res.locals.tag("template:pdp-v3");
  res.set("Cache-Control", "public, s-maxage=3600, stale-while-revalidate=60");
  res.send(renderProduct(product));
});

Note the two different separators: Cloudflare and Akamai parse a comma-delimited list, Fastly parses a space-delimited one. Emitting both headers is harmless — each CDN ignores the one it does not understand — and it means the same origin can sit behind either platform.

Both Cloudflare and Fastly strip their own tag header before the response reaches the browser, so tags never leak to clients. If you are behind a CDN that does not strip them, remove the header in an edge function; a Surrogate-Key list is an inventory of your internal identifiers and there is no reason to publish it.

Step 3 — Stay under the header size limit

This is where most implementations quietly break. Cloudflare caps the Cache-Tag header at 16 KB after the edge processes it, with a limit on individual tag length, and it will drop the header rather than reject the response. Fastly is similarly bounded. A listing page that tags every item it renders can blow past that on a 200-item paginated view, and nothing in the response tells you it happened.

Cap the list deterministically at the origin so behaviour is predictable rather than truncated:

const MAX_HEADER_BYTES = 15 * 1024;   // leave headroom under the 16 KB ceiling
const MAX_TAG_BYTES = 256;

function capTags(tags) {
  const kept = [];
  let bytes = 0;
  // Sort so the most specific, highest-value tags survive a cap.
  const ordered = tags
    .filter((t) => Buffer.byteLength(t) <= MAX_TAG_BYTES)
    .sort((a, b) => rank(a) - rank(b));
  for (const t of ordered) {
    const cost = Buffer.byteLength(t) + 1;
    if (bytes + cost > MAX_HEADER_BYTES) {
      // Fall back to a coarser tag rather than silently losing coverage.
      if (!kept.includes("template:pdp-v3")) kept.push("template:pdp-v3");
      break;
    }
    kept.push(t);
    bytes += cost;
  }
  return kept;
}

function rank(tag) {
  return tag.startsWith("product:") ? 0 : tag.startsWith("collection:") ? 1 : 2;
}

If a page genuinely depends on hundreds of entities, that is a signal to change the page rather than the header: paginate it, or accept a coarser tag and a shorter TTL. A listing that turns over constantly is a poor caching candidate at any TTL.

Tag header budget An annotated origin response header block showing the Cache-Tag and Surrogate-Key lines, with three callouts naming the header byte ceiling, the per-tag length ceiling, and the number of tags accepted per purge API call. Origin response, before the edge strips the tag headers HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Cache-Control: public, s-maxage=3600 Cache-Tag: product:8842, collection:running,template:pdp-v3 Surrogate-Key: product:8842 collection:running template:pdp-v3 comma list for Cloudflare, space list for Fastly Whole header 16 KB ceiling Single tag keep under 256 B Per purge call batch 30 tags

Step 4 — Purge by tag from the write path

The purge belongs next to the write, not next to the deploy. Anything that mutates a row should enqueue the corresponding tag; a small worker drains that queue and calls the CDN.

A database trigger writing to an outbox table is the most robust wiring, because the enqueue is part of the same transaction as the write — if the transaction rolls back, no purge is queued for a change that never happened:

CREATE TABLE cache_purge_outbox (
  id          BIGSERIAL PRIMARY KEY,
  tag         TEXT NOT NULL,
  enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  purged_at   TIMESTAMPTZ
);
CREATE INDEX ON cache_purge_outbox (purged_at) WHERE purged_at IS NULL;

CREATE OR REPLACE FUNCTION queue_product_purge() RETURNS trigger AS $$
BEGIN
  INSERT INTO cache_purge_outbox (tag) VALUES ('product:' || NEW.id);
  IF NEW.collection IS DISTINCT FROM OLD.collection THEN
    INSERT INTO cache_purge_outbox (tag) VALUES ('collection:' || NEW.collection);
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER product_purge AFTER INSERT OR UPDATE ON products
  FOR EACH ROW EXECUTE FUNCTION queue_product_purge();

The worker claims a batch of distinct unpurged tags, calls the provider, and marks them done. On Cloudflare:

curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"tags":["product:8842","collection:running"]}' | jq '.success'

On Fastly, a batch purge takes the whole key list in one body and accepts a soft-purge header so objects are marked stale instead of evicted:

curl -sS -X POST "https://api.fastly.com/service/$FASTLY_SERVICE_ID/purge" \
  -H "Fastly-Key: $FASTLY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Fastly-Soft-Purge: 1" \
  --data '{"surrogate_keys":["product:8842","collection:running"]}'

Soft purge is the better default here: the object stays available while the edge revalidates, so a purge during peak traffic does not become an origin spike. It only helps if the response also carries a stale directive, which is the pairing described in serving stale-if-error during an origin outage.

CloudFront has no tags, so you emulate them with an index. Write the tag-to-path mapping to DynamoDB as you render, then expand tags to paths at purge time and call create-invalidation with the resulting list. It works, but you now own the index’s correctness and the per-path invalidation cost, which is exactly the accounting tags were meant to remove.

Step 5 — Make the purge idempotent and safe to retry

A purge is naturally idempotent: purging product:8842 twice has the same effect as purging it once. Lean on that. The worker should retry on 5xx and 429 with exponential backoff and never treat a duplicate as an error.

#!/usr/bin/env bash
set -euo pipefail
tags_json="$1"   # e.g. '{"tags":["product:8842"]}'
for attempt in 1 2 3 4 5; do
  code=$(curl -sS -o /tmp/purge.json -w '%{http_code}' -X POST \
    "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
    -H "Authorization: Bearer $CF_API_TOKEN" \
    -H "Content-Type: application/json" --data "$tags_json")
  case "$code" in
    200) echo "purged: $tags_json"; exit 0 ;;
    429|5??) sleep $((2 ** attempt)); continue ;;
    *) echo "fatal purge error $code"; jq . /tmp/purge.json; exit 1 ;;
  esac
done
echo "purge exhausted retries"; exit 1

Two more properties matter at volume. Coalesce: collapse duplicate tags inside a short window (two to five seconds) before calling, because an editor saving four times in a row should produce one purge. Batch: send up to thirty tags per Cloudflare call and up to the documented key limit per Fastly batch, rather than one call per tag — the API rate limit is per call, not per tag, so batching is what keeps you under it. The same retry and verification discipline used for deploy-time invalidation in purging Cloudflare cache via API on deploy applies unchanged here.

Purge on the write path A row update fires a database trigger that inserts into an outbox table; a worker coalesces and batches the tags, calls the CDN purge API with retries, and the edge invalidates every object carrying those tags. UPDATE products price changed Outbox row same transaction Purge worker claims a batch Edge tag index objects invalidated Coalesce 5s drop duplicates Retry 429/5xx backoff, idempotent writer names the entity only 30 tags per call keeps a bulk import inside the API rate limit

Verification

Prove the loop with three requests and one purge. Warm the object first, confirm it is a hit, purge its tag, and confirm the next request misses.

URL=https://www.example.com/products/running-shoe-x

curl -sI "$URL" | grep -iE 'cf-cache-status|age'
# cf-cache-status: MISS
# age: 0

curl -sI "$URL" | grep -iE 'cf-cache-status|age'
# cf-cache-status: HIT
# age: 7

curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
  --data '{"tags":["product:8842"]}' | jq '.success'
# true

sleep 3
curl -sI "$URL" | grep -iE 'cf-cache-status|age'
# cf-cache-status: MISS
# age: 0

A MISS with age: 0 on the fourth request is the proof: the tag reached the edge, the association was recorded, and the purge matched it. Repeat the check against a second URL that carries the same tag — the collection listing — to confirm the fan-out actually works rather than the product page simply having expired.

Also confirm the tag headers do not reach clients:

curl -sI "$URL" | grep -iE 'cache-tag|surrogate-key'
# (no output — the edge consumed and stripped them)

Troubleshooting

The tag list is silently truncated

Symptom: purging product:8842 clears the product page but never the listing. The listing renders 120 products and its header exceeded the ceiling, so the edge dropped tags — or dropped the header entirely — without any error. Diagnose by requesting the listing directly from origin, bypassing the CDN, and measuring the header:

curl -sI https://origin.internal.example.com/collections/running \
  | awk '/^[Cc]ache-[Tt]ag/{print length($0)" bytes"}'

If that number is anywhere near 16384, the cap in step 3 is not running or its budget is too generous. Fix it by capping at origin and falling back to a coarser tag, and consider whether a page with 120 dependencies should be cached at a one-hour TTL at all.

Purge returns success but the page is still stale

The API answering {"success": true} only means the control plane accepted the instruction. Two layers commonly still hold the old bytes. The first is a shield or upper-tier cache: if you run tiered cache and origin shield, verify the upper tier cleared, not just the edge you happened to hit. The second is the browser. A CDN purge never reaches a client cache, so if you set a long max-age alongside s-maxage, returning visitors keep their local copy regardless. Check age and cache status from a cold client, and keep the client-facing max-age short while the shared s-maxage stays long.

One tag purges half the site

Symptom: origin CPU spikes and hit ratio collapses right after a routine content edit. Someone attached a low-cardinality tag such as template:pdp-v3 to every page and then purged it. Audit which tags exist and how broad each one is by sampling live responses at origin:

for path in / /products/running-shoe-x /collections/running /api/v2/products/8842; do
  echo "== $path"
  curl -sI "https://origin.internal.example.com$path" | grep -i '^cache-tag' | tr ',' '\n' | head -20
done

Template and tenant tags are legitimate, but they are release-level instruments, not write-path ones. Restrict which code paths may purge them, and never let a row-level trigger emit one.

A bulk import produces a purge storm

Symptom: an overnight catalog sync writes 40,000 rows, the trigger enqueues 40,000 tags, and the worker either hits the purge API rate limit or invalidates so much of the cache at once that the origin sees a cold-start flood at breakfast. The fix has three parts. Coalesce aggressively during imports — a five-second window across a bulk job collapses thousands of tags into a handful of batches. Cap the worker’s own throughput so it drains at a rate the origin can absorb, rather than as fast as the queue allows. And for jobs that genuinely change everything, skip tags entirely: it is cheaper and more predictable to shorten the TTL for the duration of the import and let objects expire naturally than to evict the whole cache in ninety seconds.

Tags never get associated at all

If purging by tag never has any effect and the header is present at origin, check that the response was cacheable in the first place. A Set-Cookie header, Cache-Control: private, or a no-store sneaking in from a framework default means the edge never stored the object, so there was nothing to associate a tag with. Confirm with a second request: if cf-cache-status is DYNAMIC or BYPASS rather than HIT, fix cacheability before debugging the tag pipeline. For static assets, consider whether you need purging at all — fingerprinting assets for immutable caching removes the problem instead of solving it.

Frequently Asked Questions

What is the difference between a cache tag and a surrogate key? They are the same mechanism under two vendor names: Cloudflare calls the header Cache-Tag and Akamai calls it Edge-Cache-Tag, while Fastly calls it Surrogate-Key. The only practical differences are the delimiter — commas for Cloudflare and Akamai, spaces for Fastly — and the purge API shape. Emitting both headers from origin costs nothing and keeps you portable.

How many tags should one response carry? As many as it genuinely depends on, capped by the header budget. A product page with three or four tags is typical; a listing page with a tag per item is fine up to a few hundred. Once you are approaching the 16 KB ceiling you have a page design problem rather than a tagging problem, and the honest fix is pagination or a shorter TTL.

Can I purge by tag on a non-Enterprise Cloudflare plan? No. Purge by tag, prefix, and hostname are Enterprise features on Cloudflare; other plans get purge-by-URL and purge-everything. If you need entity-level invalidation on a lower plan, either maintain a tag-to-URL index in your application and purge the URLs, or run the workload on a platform where surrogate keys are included.

Should tag purges be soft or hard? Prefer soft where the platform offers it, as Fastly does with Fastly-Soft-Purge: 1. A soft purge marks objects stale so the edge can keep serving them while it revalidates, which turns a purge of a popular tag into a background refresh instead of an origin spike. Soft purge only helps when the response also carries a stale-while-revalidate directive, so configure the two together.

Where should the purge call live — the deploy pipeline or the application? Both, for different reasons. Deploy-time purges handle template and asset changes, where the trigger is a release. Tag purges belong in the application write path, where the trigger is a data change, and putting them there is what removes the coupling between “who edits a product” and “which URLs render it”. Fire them from an outbox in the same transaction as the write so a rolled-back change never purges.

Back to Cache Purging & Invalidation