Skip to content

Images & Thumbnails

Every image a template or island renders comes out of the filestore, and the filestore will derive a downscaled rendition on request. Ask for the size you actually draw. A URL with no size serves the file the user uploaded — which for product photography or a blog cover is routinely 2000px wide and two orders of magnitude more bytes than the slot on screen.

Renditions are requested with ?s=<pixels>, where the number is the longest edge in real pixels — not a CSS width. A 30px thumbnail on a 2x display needs ?s=64.

Only these sizes derive:

32 64 128 256 512 1024

Anything else is served as the original. So never invent a number: pick one from the list, or use a helper below, which does it for you.

Renditions are WebP, generated on first request and cached on disk under the source file’s content hash. Replacing an image derives a fresh rendition automatically — there is no invalidation step, and nothing to purge.

Two formats deliberately have no renditions and always serve the original: SVG (already resolution-independent) and GIF (resizing would flatten the animation to one frame).

Use the image and image_srcset filters. They are installed on every module template environment alongside money, date and video, so a theme or app-store section gets them without importing anything.

<img src="{{ product.image|image(512) }}"
srcset="{{ product.image|image_srcset(256, 512, 1024) }}"
sizes="(max-width: 767px) 50vw, (max-width: 1023px) 33vw, 25vw"
alt="{{ t(product.name) }}"
loading="lazy" decoding="async">

Both filters accept an attachment id or an already-built URL, so the same expression works whether the render context handed you a raw field value or a resolved address.

{{ x|image }} with no argument is the original — correct for og:image and JSON-LD, where crawlers want at least 1200px on the long edge and the URL is fetched by a scraper rather than by every visitor.

With a srcset and no sizes, the browser assumes the image fills the viewport and takes the largest candidate every time — worse than shipping no srcset at all. Describe the CSS width the image occupies at each breakpoint. If the element has a fixed pixel width, skip srcset entirely and give image() that one size.

Add loading="lazy" decoding="async" to anything below the fold. The one image that must not be lazy is the page’s largest contentful paint — a product hero, a post cover. Mark that one fetchpriority="high" instead; a lazy hero waits for layout before it starts downloading and measurably delays the paint.

window.fullfinity exposes the same two helpers:

const { imageUrl, imageSrcset } = window.fullfinity;
html`<img src=${imageUrl(line.image, 128)} width="64" height="64"
loading="lazy" decoding="async" />`

imageUrl(id, size) builds the address; imageSrcset(id, ...sizes) builds the candidate list. Both accept an id or an existing URL. Do not concatenate /filestore/view/<id> by hand — that is what leaves a 44px cart thumbnail downloading a full-resolution photograph.

When a route serializes images for an island to render, send the rendition the island will draw, plus its candidates:

from fullfinity.engine.utils import image_srcset, image_url
{
"image": image_url(product.image, 512),
"image_srcset": image_srcset(product.image, 256, 512, 1024),
}

A public attachment is served with a shared-cacheable Cache-Control, so a CDN or reverse proxy in front of the site can serve it without reaching the origin. Access-controlled attachments stay private.

When attachments are kept in object storage, the application answers these URLs with a short-lived redirect to the file rather than sending the bytes itself, so images never travel through a worker. The caching described here is unchanged — the policy is carried into the redirect — and nothing in your templates changes: keep using the same helpers and the same /filestore/view/<id> addresses.

A plain /filestore/view/<id> URL addresses a mutable record, so it gets a day of reuse plus a week of background revalidation — long enough that a second page view is free, short enough that a replaced image propagates on its own. A URL that also carries the content hash (v=, minted by build_file_url) is immutable by construction and cached for a year.