Invalidating Nested Content with Hierarchical Tags

Problem Statement

A documentation section is renamed. Every page beneath it shows the section name in its breadcrumb, its navigation and its title suffix, so all of them are now wrong — but only those, not the rest of the site. Tags are a flat namespace with no notion of containment, and the naive options are both bad: purge everything, or enumerate the descendants.

Prerequisite Concepts

Step-by-Step Resolution

Step 1 — Model the hierarchy as path prefixes

Every node in a tree is identified by the chain of ancestors above it. That chain can be expressed as a set of flat tags, one per level:

/guides/http/caching/validators
  -> guides
  -> guides.http
  -> guides.http.caching
  -> guides.http.caching.validators

Each tag names a subtree. Purging guides.http.caching invalidates everything below it and nothing above or beside it.

A page's prefix chain, level by levelEach page emits one tag per ancestor level, so any level can be purged independently and the blast radius is exactly that subtree.WIDEST SCOPE1guidesevery guide on the site2guides.httpthe HTTP section3guides.http.cachingthe caching subsection4guides.http.caching.validatorsthis page and its variantsTHIS PAGE ONLY
Four tags on one page, four possible purge scopes

Step 2 — Emit one tag per level

The chain is computed from the page’s own path, so it needs no external index:

def prefix_tags(path, max_depth=6):
    parts = [p for p in path.strip("/").split("/") if p][:max_depth]
    return [".".join(parts[:i + 1]) for i in range(len(parts))]

# /guides/http/caching/validators ->
# ['guides', 'guides.http', 'guides.http.caching', 'guides.http.caching.validators']
response["Surrogate-Key"] = " ".join(prefix_tags(request.path))
GET /guides/http/caching/validators
Surrogate-Key: guides guides.http guides.http.caching guides.http.caching.validators
Cache-Control: public, s-maxage=86400

Step 3 — Purge at the level that changed

The rule is to purge the narrowest tag that covers the change:

Edit type to purge levelChoosing the narrowest tag that covers the change keeps repopulation proportional to what actually became stale.Purge tagPages evictedOne page editedguides.http.caching.validators1Subsection reorderedguides.http.caching12Section renamedguides.http140Global navigation changedguides900
Four edits, four purge levels
# A section rename invalidates the section and everything beneath it
curl -sX POST "https://api.fastly.com/service/$SERVICE_ID/purge/guides.http" \
  -H "Fastly-Key: $FASTLY_API_TOKEN"

Step 4 — Bound the depth

A deep tree produces long tag headers, and vendors impose a budget. Capping the depth means very deep pages share their deepest ancestor tag rather than having their own — acceptable, because a page that deep is rarely purged individually:

tags = prefix_tags(request.path, max_depth=5)

Five levels covers almost any documentation tree, produces at most five short tags, and leaves room in the header for the entity and template tags a page also needs.

Tag header length by hierarchy depthPrefix tags grow linearly with depth and quadratically in total characters, so a depth cap is what keeps the header inside the vendor budget.3 levels74bytes5 levels186bytes7 levels352bytes10 levels690bytes approaching vendor limitsplus the entity and template tags each page also carries
Surrogate-Key header bytes by depth for a typical path

Step 5 — Verify the subtree boundary

The test that matters is that the boundary is where you think it is — descendants gone, siblings untouched:

curl -sX POST "https://api.fastly.com/service/$SERVICE_ID/purge/guides.http.caching" \
  -H "Fastly-Key: $FASTLY_API_TOKEN"

for p in /guides/http/caching/validators \
         /guides/http/caching/directives \
         /guides/http/methods \
         /guides/css/layout; do
  printf '%-38s ' "$p"
  curl -sI "https://example.com$p" | awk -F': ' '/[Aa]ge/{print "Age " $2}'
done

The first two should report Age near zero; the last two should report a climbing Age. Anything else means the prefix chain is not being emitted consistently.

Expected Output / Verification

A correct implementation gives a clean cut at the purged level:

/guides/http/caching/validators   Age 1     <- evicted, correct
/guides/http/caching/directives   Age 2     <- evicted, correct
/guides/http/methods              Age 41204 <- sibling, untouched
/guides/css/layout                Age 86022 <- unrelated, untouched

Repeat the check after any change to the routing layer, since a path rewrite that alters the segments a page sees will silently change the tags it emits and therefore which purges reach it.

Edge Cases

  • A page reachable by several paths. Emit one prefix chain per path. The tag count grows but remains far smaller than the URL set a purge would otherwise need.
  • Rewrites changing the visible path. If the application sees a different path from the one the visitor requested, the tags describe the internal tree rather than the public one, and purges by public path will miss.
  • A depth cap that hides a real level. Capping at five levels on a tree that is genuinely seven deep means the two deepest levels cannot be purged independently. Confirm the cap against the real maximum depth.
  • Trailing-slash variants. /guides/http/ and /guides/http produce the same segment list, so the tags match — but only if the splitting logic strips empty segments, which is worth a unit test.
  • Very large subtree purges. Purging a top-level tag can evict thousands of pages at once. Use a soft purge where the vendor supports it so the edge keeps answering while it repopulates.
  • Sibling pages that quote a parent’s content. A page outside the subtree that renders a fragment of the renamed section is not covered by the prefix chain and will keep showing the old name. Where that pattern exists, the quoting page should also emit the quoted node’s tag, which is the point at which a pure hierarchy needs a flat entity tag alongside it.
  • Localised trees. A locale segment at the root multiplies every tag by the number of locales. Either include the locale in the chain deliberately, or strip it so a content change purges every language at once.

Frequently Asked Questions

Why not just purge by URL prefix?

Most vendors do not support prefix purging at all, and those that do usually restrict it to higher plan tiers or impose rate limits. Path-prefix tags achieve the same effect using the ordinary tag mechanism, which is universally available.

How many levels should a page tag?

As many as the hierarchy has, up to the header budget. Four to six levels covers almost every real content tree, and the tags are short because they are derived from path segments.

Does this over-purge?

By design. Purging an ancestor invalidates the whole subtree, which is correct when a change to the parent genuinely affects its descendants — a renamed section appears in every child’s breadcrumb. Purge the narrowest tag that covers the change.

What if the hierarchy is a graph rather than a tree?

Emit one prefix chain per path through the graph. A page reachable by three routes carries three chains, which is still far cheaper than enumerating the URLs affected by a change to any of them.


Back to Tag-Based Cache Invalidation Patterns