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:
- What shape an image takes in the JSON, and which sizes exist.
- 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
nullwhen there is no image. width/heightare 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'scarries 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:
imageandcoverchange 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_RENDITIONSsilently 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
RETAINwith 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_commitand failures are non-fatal, so the worst case is a slow save, never a failed one.
Related¶
- 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.