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 0360 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.

python
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 caching decision for one IIIF Image API request, with a rights-aware branch Left to right: an image request is canonicalized (full becomes max, 0.0 rotation becomes 0, redundant zeros in crop boxes are stripped) into a stable cache key, then a rights gate classifies the identifier. Public identifiers flow up to an immutable, year-long cache entry served as a shared hit. Gated or embargoed identifiers flow down to a bypass with private, no-store, fetching from a signed per-viewer origin so restricted bytes are never stored at a shared edge. Re-deriving the master purges the surrogate key iiif/identifier, dropping the whole derivative family together. One canonical key, then a rights gate — gated bytes never reach the shared edge Image request region/size/rot/qual Canonicalize full→max · 0.0→0 Rights gate classify(identifier) Cache immutable max-age=1y Edge cache shared HIT Bypass cache private · no-store Signed origin per-viewer public gated re-derivation purges iiif/{id}

Edge Cases and Variants

  • info.json versus tiles. The info.json image 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. Cache info.json with a short-to-moderate TTL and Vary: Accept, and tag it with the same iiif/{identifier} surrogate key so a re-derivation that changes dimensions purges the description alongside the pixels. parse_image_path returns None for 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 GATED and EMBARGOED tiers, 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 PUBLIC on 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 None and 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:

python
# 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 fullmax, normalize 0.00, 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.