Purging Localized Assets by Cache Tag

This guide, part of Asset Duplication & CDN Sync, designs cache tags for localized media and the pages that use it, so that a publish purges exactly what changed: one asset variant in one locale, its resized renditions, and the pages that display it, and nothing else. It covers tag naming, fan-out from CMS webhooks, CDN provider limits and how to verify that purges worked.

Purging by URL does not scale for localized media. One image can have a dozen locale variants, each with five or six resized renditions in two formats, which makes over a hundred URLs, most of which the purge job does not know about because the image service created them on demand. Wildcard purges are the usual workaround, and they are too broad: a purge of /assets/hero* clears every locale and every rendition, and the next minutes of traffic go to the origin. Tags solve both problems. Every response carries labels that describe what it contains, and a purge by label removes every response with that label, however many URLs there are.

One German variant change, three kinds of purgeA change to the German variant of the hero asset purges the tag for that asset and locale, which covers the original and all its resized renditions, and the page tag for German pages that display it, while French and English caches stay intact.hero-42 (de)changedasset:hero-42:depage-asset:hero-42:deOriginal + 12renditions (de)German pagesusing hero-42fr, en cachesuntouchedpurgepurge
Tags follow the content, so the purge covers every URL the variant appears under.

The Problem

A news site served images through an image service behind its CDN, with renditions for eight widths in AVIF, WebP and JPEG. When a photo was replaced in one locale’s article, the purge job purged the original URL. Renditions stayed cached for their full lifetime of a week, so readers on phones and tablets, who received renditions, kept seeing the old photo. The team switched to wildcard purges on the asset id, which fixed that, but a breaking news photo swap then cleared renditions in all nine locales and sent a surge of image requests to the origin during the busiest hour of the day.

How Tag-Based Purging Works

Every response carries tags. The image service or the origin adds a header, Cache-Tag on Cloudflare, Surrogate-Key on Fastly, Edge-Cache-Tag on Akamai, Cache-Tag on Vercel’s platform, with space- or comma-separated tags. For an asset rendition, the tags name the asset, the locale and optionally the format. For an HTML page, the tags name the entries and assets it renders.

Purges name tags, not URLs. The purge API removes every cached response carrying the tag, including renditions created on demand that the purge job never knew about.

Tag design matches change granularity. Tags are chosen so that each kind of change maps to exactly one tag: a variant replaced in one locale, an asset replaced in every locale, a page’s content changed.

Tag scheme for localized media and pagesTags attached to asset responses and page responses, with the change that triggers a purge of each.TagOn responsesPurged whenasset:{id}:{locale}original and renditions of one variantthat variant changesasset:{id}every variant and renditionasset replaced in all localespage-asset:{id}:{locale}pages displaying the variantvariant or its alt text changesentry:{id}:{locale}pages rendering the entryentry published in that locale
Each kind of change purges exactly one tag family.

Implementation

The image route adds tags to every rendition it serves. Here, a Next.js route handler proxies the image service and sets the tag header; with a managed image service, configure it to add tags or wrap it in an edge function.

TypeScript
// app/img/[id]/[locale]/route.ts: /img/hero-42/de?w=640&fmt=avif
import { resolveVariant } from "@/lib/media/manifest";

const CHAINS: Record<string, string[]> = { de: ["de", "default"], "de-AT": ["de-AT", "de", "default"], fr: ["fr", "default"] };

export async function GET(req: Request, { params }: { params: Promise<{ id: string; locale: string }> }) {
  const { id, locale } = await params;
  const url = new URL(req.url);
  const variant = await resolveVariant(id, CHAINS[locale] ?? [locale, "default"]);
  if (!variant) return new Response("not found", { status: 404 });

  const upstream = new URL(`${process.env.IMAGE_SERVICE_URL}/${variant.objectKey}`);
  upstream.searchParams.set("w", url.searchParams.get("w") ?? "1200");
  upstream.searchParams.set("fmt", url.searchParams.get("fmt") ?? "webp");

  const res = await fetch(upstream);
  const headers = new Headers(res.headers);
  // Tag with the requested locale, not the resolved one: a later de variant must purge this fallback response.
  headers.set("Cache-Tag", [`asset:${id}`, `asset:${id}:${locale}`].join(","));
  headers.set("Cache-Control", "public, s-maxage=604800, stale-while-revalidate=86400");
  return new Response(res.body, { status: res.status, headers });
}

Tagging with the requested locale matters. When German visitors receive the default variant because no German one exists yet, their cached responses must be purged when a German variant is published. Tagging them with the resolved locale, default, would leave them cached.

The webhook handler maps asset events to purges.

TypeScript
// app/api/asset-webhook/route.ts
import { purgeTags } from "@/lib/cdn";
import { revalidateTag } from "next/cache";
import { verifyWebhook } from "@/lib/webhooks";

export async function POST(req: Request) {
  const raw = await req.text();
  if (!verifyWebhook(raw, req.headers.get("x-webhook-signature"))) return new Response("invalid", { status: 401 });
  const evt = JSON.parse(raw) as { assetId: string; locales: string[]; allLocales: boolean };

  const assetTags = evt.allLocales ? [`asset:${evt.assetId}`] : evt.locales.map((l) => `asset:${evt.assetId}:${l}`);
  const pageTags = evt.locales.map((l) => `page-asset:${evt.assetId}:${l}`);

  await purgeTags([...assetTags, ...pageTags]);      // CDN: renditions and cached HTML
  for (const tag of pageTags) revalidateTag(tag);    // framework data cache for pages
  return Response.json({ purged: assetTags.length + pageTags.length }, { status: 202 });
}

When a variant is published for a locale that falls back to others, such as de with de-AT falling back to it, include the dependent locales in locales, so their fallback responses are purged too.

Provider limits

Every CDN limits tags. Typical constraints are a maximum header length, a maximum number of tags per response, a maximum tag length and a rate limit on purge requests. Keep tags short, use ids rather than slugs, and avoid tagging pages with every asset on them when a page shows hundreds of images; tag such pages with a coarser tag instead, such as the gallery entry id. Batch purges where the API accepts several tags per request, and queue them to stay within rate limits.

Configuration Reference

Item Recommendation Why
Asset tags asset:{id} and asset:{id}:{locale} Purge one variant or all of them.
Locale in tag requested locale, not resolved Fallback responses are purged when a variant arrives.
Page tags page-asset:{id}:{locale} Pages that display the variant update too.
Dependent locales include locales whose chains reach the changed one No stale fallbacks.
Rendition lifetime long, with tag purges Freshness from purges, not from short TTLs.
Purge requests batched and queued Stay within provider rate limits.

Gotchas & Edge Cases

  • Tags on the wrong layer. If the image service sits behind a second CDN or a proxy that strips unknown headers, tags never reach the edge that caches renditions. Check headers at each layer.
  • Browser caches. Tag purges clear CDN caches, not browsers. Keep browser lifetimes short for URLs that are not immutable, or use hashed object URLs that change with content.
  • Cached HTML referencing old URLs. If page HTML embeds versioned image URLs, pages must be purged when the variant changes, or they keep pointing at the old version. That is what the page tags are for.
  • Too many purges at once. A bulk asset import can generate thousands of purges. Coalesce them into asset-level or type-level tags during imports.

Worked Example

The news site added asset:{id} and asset:{id}:{locale} tags to every rendition and page tags to article HTML, and switched purges from wildcards to tags. The next breaking news photo swap in the German edition purged 26 cached renditions and 14 German pages, and nothing in the other eight editions. Origin image requests during the swap stayed within normal levels, and readers on every device saw the new photo within seconds.

Cached objects cleared by one photo swapCached responses removed from the CDN when one locale's photo was replaced, with wildcard purges on the asset id compared with locale-scoped tag purges.Wildcard purge1870 cached responses clearedLocale tag purge40 cached responses cleared
Locale-scoped tags cleared only the German renditions and pages.

Verifying Purges

A purge API that returns success tells you the request was accepted, not that every edge location removed the object. Verify important purges by requesting an affected rendition and page from a few regions shortly afterwards, and checking the cache status and age headers: a hit with a large age means the purge did not reach that location or the response was not tagged as expected. Automate this for a sample of purges and alert when verification fails repeatedly. During development, log the tag header of every response in staging, and review it for new routes; untagged responses are the most common reason purges appear to do nothing. Most providers also offer purge logs or analytics, which help confirm that purges are arriving at the rate you expect.

Rollout Checklist

  • Add asset and locale tags to every rendition, using the requested locale.
  • Add page tags for assets displayed on each page.
  • Purge tags from asset webhooks, including dependent fallback locales.
  • Respect provider limits on tag length, count and purge rate.
  • Verify purges from several regions for important changes.
  • Coalesce purges during bulk imports.

Frequently Asked Questions

Which CDNs support tag purging?

Most major ones, under different header names: Fastly surrogate keys, Cloudflare cache tags on higher plans, Akamai edge cache tags and platform caches such as Vercel’s. Check plan limits.

Are tags visible to users?

Some CDNs strip tag headers before responding to clients; others pass them through. Strip them yourself if you do not want to reveal internal ids.

Should images and pages share tags?

Use related but distinct tags, as above, so an asset change can purge renditions without necessarily purging every page, and vice versa.

What about CDNs without tags?

Purge by URL using a list of known renditions, and prefer immutable hashed URLs so that most content never needs purging at all.