Operational Context
A DAMS administrator fronts the museum’s image server with a CDN so that a deep-zoom viewer pulling hundreds of tiles per pan does not hammer the origin, and two failures show up in the same week. First, the edge hit rate is embarrassingly low — the origin CPU graph barely drops even though the CDN is “on” — because functionally identical requests are landing on distinct cache objects. Second, and far worse, a curator reports that an embargoed acquisition was briefly visible full-resolution to the public: an authorized preview was stored by a shared edge node and then replayed to an anonymous viewer. Both problems trace to the same place — the caching layer that sits between the IIIF Image API configuration and the public internet, the last stage of the IIIF image delivery pipeline. This page resolves both: it normalizes the combinatorial Image API request space into stable cache keys, marks public derivatives immutable, invalidates them precisely when a master is re-derived, and makes rights-gated media unstorable at a shared edge so it can never leak to the wrong audience.
Root Cause Analysis
The IIIF Image API 3.0 request grammar is a Cartesian product. Every image identifier expands into {identifier}/{region}/{size}/{rotation}/{quality}.{format}, and each of those four parameters is itself a family: region may be full, square, an x,y,w,h pixel box, or a pct: fraction; size may be max, w,, ,h, !w,h, or pct:n; rotation runs 0–360 with an optional mirror !; quality is default, color, gray, or bitonal. A single object can be requested legitimately in tens of thousands of ways, and a tiled viewer will exercise a large slice of that space in one session.
The first root cause is keying on the raw request URL. When the cache key is the literal path, .../full/max/0/default.jpg and .../full/max/0.0/default.jpg and .../full/full/0/default.jpg are three different keys for one identical set of pixels. The full size alias (a IIIF 2.x spelling of max), a 0.0 rotation, an uppercased format extension, or a redundant leading zero in a crop box each mint a fresh cache object. The result is cache thrash: the working set fragments across near-duplicate entries, eviction churns, and the origin re-renders derivatives it has already produced. Normalizing the request into one canonical key before it touches the cache is the entire fix for the hit-rate problem.
The second root cause is caching without regard to rights. A CDN edge is a shared cache — one stored object is replayed to every client that asks for that key. That is exactly the behavior you want for an openly licensed derivative and exactly the behavior that leaks gated media: if an authorized, authenticated request for an embargoed image is allowed to populate the shared cache, the next anonymous request for the same key is served the restricted bytes with no authorization check at all. Access control that lives only at the origin is bypassed the moment the edge answers. Rights state has to be a first-class input to the caching decision, not an afterthought bolted on at the origin.
The third root cause is treating derivatives as mutable. A derivative is a pure function of the master pixels and the request parameters, so for the life of those pixels it never changes — which argues for a very long TTL and an immutable directive. But masters do get re-derived: a better scan replaces a lossy one, an orientation is corrected, a crop is redrawn. Short TTLs are the wrong tool for that (they pay a re-validation tax on every object to catch the rare change); the right tool is explicit invalidation keyed to the identifier. Without a purge-by-tag mechanism, re-derivation either serves stale pixels until the TTL lapses or forces punishingly short TTLs that defeat the cache.
Canonical Solution
The edge worker below runs on each Image API request. It parses the path against the Image API grammar, canonicalizes every parameter so equivalent requests collapse to one key, classifies the identifier’s rights tier, and returns a caching decision: a stable key plus Cache-Control for public derivatives, or a hard bypass for anything rights-gated. A per-identifier surrogate key tags every derivative of a master so re-derivation can purge the whole family at once.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class Tier(str, Enum):
PUBLIC = "public" # openly licensed — safe to cache hard at a shared edge
GATED = "gated" # rights-restricted — authorized per viewer, never shared
EMBARGOED = "embargoed" # dark until a release date — must not be stored at all
@dataclass(frozen=True)
class ImageRequest:
identifier: str
region: str
size: str
rotation: str
quality: str
fmt: str
def parse_image_path(path: str) -> ImageRequest | None:
# The four segments after the identifier are fixed by the Image API 3.0 grammar:
# {identifier}/{region}/{size}/{rotation}/{quality}.{format}. info.json and bare
# identifier redirects have no "." in the final segment and cache under other rules.
parts = [p for p in path.strip("/").split("/") if p]
if len(parts) < 5 or "." not in parts[-1]:
return None
identifier = "/".join(parts[:-4])
region, size, rotation, quality_fmt = parts[-4:]
quality, _, fmt = quality_fmt.rpartition(".")
return ImageRequest(identifier, region, size, rotation, quality, fmt)
_SIZE_ALIASES = {"full": "max"} # IIIF 2.x "full" is the 3.0 "max" — one derivative
def _canon_floats(group: str) -> str:
# Collapse "016.0,0,100,100" and "16,0,100,100" onto one spelling so equivalent
# crop boxes share a key instead of each minting a fresh derivative.
out: list[str] = []
for token in group.split(","):
try:
value = float(token)
except ValueError:
return group # malformed — leave it for the origin to reject with a 400
out.append(str(int(value)) if value.is_integer() else repr(value))
return ",".join(out)
def _canon_region(region: str) -> str:
r = region.lower()
if r in {"full", "square"}:
return r
if r.startswith("pct:"):
return "pct:" + _canon_floats(r[4:])
return _canon_floats(r) # x,y,w,h pixel box
def _canon_rotation(rotation: str) -> str:
mirrored = rotation.startswith("!")
body = rotation[1:] if mirrored else rotation
try:
degrees = float(body) % 360 # 360 and 0 are the same rotation
except ValueError:
return rotation
body = str(int(degrees)) if degrees.is_integer() else repr(degrees)
return ("!" if mirrored else "") + body
def cache_key(req: ImageRequest) -> str:
# A canonical, order-fixed key: two spellings of one derivative resolve to a single
# cache object, so the edge stores each pixel result once, not once per URL variant.
return "/".join([
req.identifier,
_canon_region(req.region),
_SIZE_ALIASES.get(req.size.lower(), req.size.lower()),
_canon_rotation(req.rotation),
req.quality.lower() + "." + req.fmt.lower(),
])
def surrogate_key(identifier: str) -> str:
# One purge tag per source image. Every region/size/rotation/quality derivative is
# tagged with it, so re-deriving the master invalidates the whole family in one call.
return f"iiif/{identifier}"
@dataclass(frozen=True)
class CacheDecision:
key: str | None # None means: neither read nor write the shared cache
cache_control: str
surrogate_key: str
def decide(req: ImageRequest, tier: Tier) -> CacheDecision:
tag = surrogate_key(req.identifier)
match tier:
case Tier.PUBLIC:
# Content-addressable and immutable for the life of the source pixels: cache
# for a year and rely on purge-by-tag — never short TTLs — to reflect change.
return CacheDecision(cache_key(req), "public, max-age=31536000, immutable", tag)
case Tier.GATED | Tier.EMBARGOED:
# A shared edge must never hold rights-gated bytes it could replay to the
# next, unauthorized viewer. Bypass storage and defer to a signed origin.
return CacheDecision(None, "private, no-store", tag)
def handle(path: str, classify) -> CacheDecision | None:
req = parse_image_path(path)
if req is None:
return None # info.json and non-image routes carry their own cache policy
return decide(req, classify(req.identifier))classify is the single seam where rights enter the edge: it maps an identifier to a Tier, ideally from a compact, edge-replicated allowlist of openly licensed identifiers rather than a synchronous origin call on the hot path. Because cache_key is pure and deterministic, the same derivative always resolves to the same object; because decide returns key=None for anything not PUBLIC, a rights-gated request can neither be served from nor written to the shared cache. The following diagram traces a request through both branches.
Edge Cases and Variants
- info.json versus tiles. The
info.jsonimage description is small, negotiated (Accept/Content-Type), and changes when tile sizes,maxWidth, or available sizes are reconfigured, whereas tiles are large and effectively immutable. Cacheinfo.jsonwith a short-to-moderate TTL andVary: Accept, and tag it with the sameiiif/{identifier}surrogate key so a re-derivation that changes dimensions purges the description alongside the pixels.parse_image_pathreturnsNonefor it precisely so it is not swept under the year-long immutable policy that suits tiles — the deep-zoom tile serving covered in serving deep-zoom IIIF tiles with pyvips is what fills the immutable branch. - Signed URLs for gated media. For
GATEDandEMBARGOEDtiers, do not merely bypass the shared cache — mint short-lived signed URLs at the origin so the authorization decision travels with each request and cannot be replayed after expiry. The edge may still cache privately per viewer, but the shared object store stays empty of restricted bytes. - Cache stampede on a cold or purged key. A newly published or freshly purged popular image can draw a burst of concurrent misses that all fall through to the origin and render the same derivative at once. Enable request coalescing (single-flight / “cache lock”) at the edge so one miss populates the key and the rest wait on it, rather than fanning out an identical render storm to the image server.
- Purge-by-prefix on a new derivative version. When a master is re-scanned, purge the surrogate key
iiif/{identifier}rather than enumerating every region/size/rotation/quality URL — the tag drops the entire derivative family in one operation. Where a CDN lacks surrogate keys, fall back to a version segment in the origin path (/v2/{identifier}/...) so the new version is a different key space and the old one ages out. - Rights tier changes. An embargo lifting or a license upgrade is a rights transition, not a pixel change: purge the surrogate key so the identifier is re-classified
PUBLICon the next request, and route the embargo timing through routing embargo dates to access tiers so the edge and the origin flip together. - Strict versus lenient parsing. The parser above is deliberately narrow — anything not matching the five-segment grammar returns
Noneand falls back to a conservative default policy. In a lenient gateway you might normalize a few known legacy spellings before parsing; never loosen it to the point that an unrecognized path defaults to a cacheable public tier.
Validation
Prove the two invariants that matter — equivalent requests share one key, and rights-gated media is never storable at a shared edge — with an assert-based test that needs no CDN:
# test_edge_cache.py -> run with: pytest -q test_edge_cache.py
def _req(**over) -> ImageRequest:
base = dict(identifier="obj42", region="full", size="max",
rotation="0", quality="default", fmt="jpg")
base.update(over)
return ImageRequest(**base)
def test_full_and_max_size_share_a_key():
# The IIIF 2.x "full" alias must not mint a second derivative for "max".
assert cache_key(_req(size="full")) == cache_key(_req(size="max"))
def test_rotation_and_region_normalize():
assert cache_key(_req(rotation="0.0")) == cache_key(_req(rotation="0"))
assert cache_key(_req(rotation="360")) == cache_key(_req(rotation="0"))
assert cache_key(_req(region="016.0,0,100,100")) == cache_key(_req(region="16,0,100,100"))
def test_format_and_quality_case_folds():
assert cache_key(_req(fmt="JPG")) == cache_key(_req(fmt="jpg"))
def test_public_derivative_is_cacheable_and_immutable():
d = decide(_req(), Tier.PUBLIC)
assert d.key is not None
assert "immutable" in d.cache_control
def test_gated_and_embargoed_are_never_shared_cached():
for tier in (Tier.GATED, Tier.EMBARGOED):
d = decide(_req(), tier)
assert d.key is None # no shared cache read or write
assert "no-store" in d.cache_control
def test_info_json_is_not_an_image_request():
assert parse_image_path("/obj42/info.json") is None
req = parse_image_path("/obj42/full/max/0/default.jpg")
assert req is not None and req.identifier == "obj42"A green run confirms that alias, rotation, crop-box, and format variants collapse onto one cache object; that a public derivative is cached hard; that gated and embargoed identifiers return no shareable key; and that info.json is excluded from the immutable image policy.
Standards Alignment
The request grammar this page normalizes is defined by the IIIF Image API 3.0 specification — the {region}/{size}/{rotation}/{quality}.{format} path and the info.json image description with its Content-Type negotiation. Caching itself is not part of that specification: it is a deployment concern layered on top, so the canonicalization rules here (treating full as max, 0.0 as 0, folding format case) are pragmatic equivalences a compliant server already honors, applied at the edge to keep the key space small. Rights, likewise, are not carried in the Image API; the machine-readable RightsStatements.org or Creative Commons URI that decides whether an identifier is PUBLIC lives in the IIIF Presentation API rights and requiredStatement, assembled in building IIIF presentation manifests and translated from license terms in mapping Creative Commons licenses to IIIF rights. The edge worker’s job is to make that upstream policy decision physically enforceable: the manifest asserts the rights, and the cache tier guarantees a shared node never serves bytes the manifest restricts.
FAQ
Why is my edge hit rate so low when the CDN is clearly enabled?
Almost always because the cache key is the raw request URL, so equivalent requests land on different objects. A tiled viewer will ask for full/max/0/default.jpg, full/full/0/default.jpg, and full/max/0.0/default.jpg — one derivative under three keys. Canonicalize the request (alias full→max, normalize 0.0→0, strip redundant zeros in crop boxes, fold format case) before it reaches the cache, and the near-duplicates collapse onto one hot object.
Can I cache a public derivative but keep an embargoed image out of the shared cache?
Yes — that is the whole point of making rights a caching input. Classify each identifier’s tier before deciding: a PUBLIC identifier gets a stable key and a long, immutable Cache-Control, while a GATED or EMBARGOED identifier returns no shareable key and private, no-store, so a shared edge node never stores bytes it could replay to an unauthorized viewer. Pair the bypass with short-lived signed URLs so authorization travels with every request.
How do I invalidate the cache when an image is re-derived?
Tag every derivative with a per-identifier surrogate key (iiif/{identifier}) and purge that tag when the master is re-scanned or corrected — one call drops the whole region/size/rotation/quality family. Prefer this to short TTLs, which pay a re-validation tax on every object to catch a rare change. Where the CDN lacks surrogate keys, put a version segment in the origin path so a new derivative version occupies a fresh key space and the old one ages out.
Should info.json be cached the same way as tiles?
No. Tiles are effectively immutable and suit a year-long immutable policy; info.json is small, content-negotiated, and changes whenever tile sizes or dimensions are reconfigured. Give it a shorter TTL with Vary: Accept, and tag it with the same surrogate key as its tiles so a dimension-changing re-derivation purges the description and the pixels together.
Related
- Configuring the IIIF Image API — parent Image API guide
- Serving Deep-Zoom IIIF Tiles with pyvips — tile source behind the cache
- IIIF Image Delivery & Manifest Generation — delivery stage overview
- Routing Embargo Dates to Access Tiers — the tier signal the edge reads
- Mapping Creative Commons Licenses to IIIF Rights — deciding the public tier