Skip to content

ADR-0009: Image rendition contract — one width ladder, generated on save

Status

Accepted

Date

2026-08-11

Context

The API served content images as their original upload: a mobile client downloaded a full-size editorial PNG to fill a card a few hundred points wide. Handling was also inconsistent — book covers went through a single width-400 Wagtail rendition, while content cards, the writing detail payload and article images all returned raw originals.

Two decisions were entangled and needed settling once, before more endpoints copied whichever pattern they happened to sit next to:

  1. What shape an image takes in the JSON, and which sizes exist.
  2. When the resized files are produced — Wagtail's default is lazy generation inside the request that first serializes the image, which puts image processing on a reader's request.

Decision Drivers

  • Must: one image contract across every endpoint, so a client writes the handling once.
  • Must: not put avoidable image processing on a mobile user's request — production tasks are 0.5 vCPU / 1024 MiB and the API asks for five renditions per image.
  • Must: a client can lay out an image before it decodes (no reflow on load).
  • Should: survive design iteration — screens change more often than sensible pixel widths do.
  • Should: leave already-optimised assets alone rather than re-encoding them.

Considered Options

Option A: keys named for design surfaces (thumb / card / hero)

Widths measured from the Figma artboard and rendered at 3x.

  • Pros: self-documenting at the call site; the client asks for a purpose, not a number.
  • Cons: couples the API contract to one screen design. A surface that changes size, or a new surface, forces either a contract change or a misleading key name.

Option B: a standard width ladder

Surface-agnostic sizes the client picks from.

  • Pros: stable across redesigns; a new screen picks the nearest width with no backend change; conventional, so it needs no explanation to a new client developer.
  • Cons: the client decides which width suits a surface — knowledge that lives on both sides.

Option C: on-demand resizing endpoint (/media/…?w=720)

  • Pros: exactly the requested size; no stored derivatives.
  • Cons: needs a resizing proxy or CDN feature we don't run, and turns every image request into compute. Rejected as disproportionate.

Decision

Option B — a standard width ladder, generated on save.

image (and a Book's cover) is an object keyed by size. Each entry carries the URL plus the rendition's true pixel dimensions:

"image": {
  "small":   { "url": "…width-360.format-webp.webp",  "width": 360,  "height": 240 },
  "medium":  { "url": "…width-720.format-webp.webp",  "width": 720,  "height": 480 },
  "large":   { "url": "…width-1080.format-webp.webp", "width": 1080, "height": 720 },
  "xlarge":  { "url": "…width-1440.format-webp.webp", "width": 1440, "height": 960 },
  "xxlarge": { "url": "…width-2048.format-webp.webp", "width": 2048, "height": 1365 }
}
  • 360 / 720 / 1080 / 1440 / 2048, WebP, defined once in readers/images.py.
  • Every key is always present; the field itself is null when there is no image.
  • width/height are the rendition's real size, already stored on the row, so serving them is free and lets the client reserve layout space before decode.
  • Renditions are never upscaled. A source narrower than a key reports its own width — which is precisely why the dimensions must be served rather than inferred from the key name.

Applies to every structured image field: a card's image, the Writing and Article detail image, and a Book's cover wherever a book card appears. Excludes:

  • theme assets (design.portal_icon, design.background), which are pre-optimised Figma exports — re-encoding them would only lose quality for no size win.
  • in-body images inside an Article body. Those live in a Markdown body (ADR-0006), and Markdown's ![alt](url) carries a single URL — it cannot express a width ladder. So an in-body image resolves to one WebP rendition, not the ladder object. See ADR-0010. This ADR governs the JSON image fields only.

Generation: on save, with lazy generation as the fallback

Wagtail generates a rendition lazily, in the first request that serializes the image at that spec: it reads the original into memory, resizes, writes the derivative to storage and records a Rendition row. Measured locally, the five-size ladder for a 12MP source costs ~330 ms; on a 0.5 vCPU task, meaningfully more — and a feed of ten cold cards would pay it ten times over, inside one request.

A post_save hook on Article, Writing and Book (data/signals.py) builds the ladder when an editor saves, so the cost lands on the person who uploaded the image and is already waiting on a save. It is deferred to transaction.on_commit, so resizing and storage writes happen outside the editor's transaction and never for a save that rolls back, and failures are logged rather than raised — a derivative that cannot be built must not block publishing.

The hook is on the content models, not on wagtailimages.Image. At upload time an Image does not know how it will be used, so an image-level hook would also build the ladder for theme portal icons and backgrounds, every one of which would be dead weight.

Wagtail's lazy path remains as the fallback, so content whose renditions were never warmed — a bulk import, or a change to the ladder — still serves correctly and simply pays once.

Consequences

Positive

  • One image contract across the API; a client writes the handling once.
  • Payload drops sharply: a card renders from a 360–720px WebP instead of a multi-MB original.
  • Layout can be reserved before decode, so images don't reflow the page as they load.
  • The ladder is one dict, so re-tuning a width is a single edit with a test pinning it.
  • Readers no longer pay for image processing in the normal case.

Negative

  • Breaking change: image and cover change from string to object. Requires a coordinated client release.
  • Five stored derivatives per content image instead of one, in the same S3 uploads bucket (Intelligent-Tiering after 90 days already applies).
  • Editor saves are slower by roughly the cost of one ladder build.

Risks & Mitigations

  • Risk: changing IMAGE_RENDITIONS silently re-costs every image, because renditions are keyed by filter spec and none of the new specs exist. → Mitigation: the ladder is asserted in a test, so a change is deliberate; lazy generation absorbs it over the first requests rather than failing.
  • Risk: rendition rows outlive their files if the media bucket is ever emptied or storage swapped, leaving rows pointing at missing objects. → Mitigation: the bucket is RETAIN with versioning off; a storage migration must copy derivatives, or purge renditions and let them regenerate (wagtail_update_image_renditions --purge-only).
  • Risk: a very large upload makes an editor's save slow. → Mitigation: warming is deferred to on_commit and failures are non-fatal, so the worst case is a slow save, never a failed one.
  • ADR-0005: API conventions — this extends them with the image contract.
  • ADR-0006: content model and serialization, which owns the Markdown projection alongside this image projection.
  • ADR-0010: in-body Markdown images — the single-rendition case this contract excludes.
  • docs/api-overview.md § Images — the client-facing statement of this contract.