Caching
Every image loads instantly after the first request, refreshes itself when you change it, and stays bounded against abuse.
Every image is rendered once and then served from cache forever. Change the source file or move the focal point and it refreshes itself everywhere — nothing to configure. Two features keep even the first request fast: prewarming and the nearby-quality fallback.
The transform pipeline
The transform endpoint renders a requested variant once
with Sharp, streams it, and persists it with Next's after() so the response isn't blocked. Every
later request for the same URL serves the stored bytes. A cold miss is never an error — it's just
the slowest possible response, a full Sharp render — and every response feeds the
render-profile registry that prewarming learns from, so
the renders a site actually uses don't stay cold: new images get them pre-generated, and
presets are generated eagerly on upload.
URLs are content-addressed: the v token is a hash of the source's filename, filesize, and
focal layers (focal point, plus the hotspot size and crop rect when set), so a variant URL is immutable and
safe to cache forever — responses ship
Cache-Control: public, max-age=31536000, immutable (plus the CDN variants) and an ETag.
Caching & abuse limits
The endpoint is public-facing, so it's bounded on several fronts:
- Variant cache (
generated-images). The first request for a size generates and stores it; later requests stream the stored copy. Replacing the file or moving the focal point purges that image's stale variants (the change/delete hooks); the preset manager panel's purge button andPOST /api/img/purge/:idclear them on demand. - Browser/CDN cache. Responses are immutable and each URL carries the
vtoken, so replacing the file or moving the focal makes already-cached responses refetch; a metadata-only edit (alt) doesn't. - Access control. Source reads run with the collection's access rules. A source you can't
read returns
404; a non-public source is servedprivatewith no shared CDN caching. The purge endpoint gates onoptions.access.manage(default: any logged-in user) and, always, on being able to read that source. - Bounded variant space (DoS). Requested dimensions snap to the
pixelStepgrid (or, for an arraypixelStep, to the grid + the ladder's exact widths) and quality buckets to a small set, both clamped tomaxDimension, so a caller can't spin up unbounded variants withw=1,2,3,…. Output never upscales past the source. - Per-image variant cap. Each image stores at most
variantLimitcached variants (default 200, a per-image field). Past the cap a new freeform size is served — from a nearby existing variant, or generated correctly but not stored — so a public URL can never accumulate unbounded files/rows on your storage. Presets are exempt. See the cap details below. - Bounded work.
maxInputPixelscaps how many pixels Sharp decodes (a decompression-bomb + memory guard), and a concurrency gate keeps a cold page that requests many sizes from saturating CPU. - SSRF + path traversal. Local reads stay inside the collection's
staticDir; cloud/relative reads self-fetch with redirects disabled, a 15s timeout, a 64MB cap, and refuse loopback / private / link-local hosts (while still allowing your configured origin).
The purge trigger and the v token key on the filename and filesize, so a replacement that
keeps the same filename (upload.overwriteExistingFiles: true, or Payload's built-in admin
crop, which overwrites in place) is still caught as long as the bytes change size. The one true
blind spot is a same-filename replacement with an identical byte count — practically only an
unchanged re-upload.
Nearby-quality fallback
A cache miss normally means the visitor waits on Sharp. With the fallback (default on), a miss
where a nearby variant already exists serves those bytes instantly while the exact variant
generates in the background. Nearby means the same crop: same fit and focal point, aspect ratio
within 8% drift, at least half the effective request width (the request clamped by the
no-upscale fit) — at any quality, in any format the client's
Accept negotiation proved it decodes (an AVIF stand-in only for AVIF requests, WebP only for
WebP/AVIF). The next request gets the accurate image.
The stand-in can never masquerade as the real thing: it's served with Cache-Control: no-store
(CDN headers too, no ETag), so nothing anywhere caches it — browser, CDN, or proxy — and it is
never persisted under the exact variant's cache key. The moment the background generation lands,
every subsequent request serves the exact bytes with the normal immutable headers. Disable with
options: { transform: { fallback: false } }.
A stand-in upgrades on the NEXT load, not live. <ResponsiveImage> is a passive <img>: a
page that received a stand-in keeps showing it until the image is requested again (navigation,
reload). Because the stand-in was never cached, that next request is guaranteed to hit the exact
variant.
The variant cap
The transform endpoint is public: any URL that hits it generates and stores a variant. Left
unbounded, an attacker enumerating ?w=…&q=…&fmt=… could accumulate huge numbers of files and DB
rows on your storage. The cap fixes that at the door.
Each image carries a variantLimit (default 200, editable per image, project default via
imagesPlugin({ options: { variantLimit } })). When a freeform cache miss would exceed the limit, the
endpoint serves the request without adding storage:
- if a nearby variant exists, that stand-in is served (
no-store); - otherwise the exact size is generated and served correctly, but not persisted (so it stays cacheable by a CDN, just not stored server-side).
Either way the stored-variant count stops growing. A real image rarely approaches 200 (a handful
of ratios × the srcset ladder × formats); raise a specific image's variantLimit in the admin if
it's genuinely heavily art-directed, and put a CDN in front so repeat traffic never re-generates.
The cap bounds storage — the permanent cost. A concurrency gate bounds CPU (a burst past the
gate sheds with 503), and presets guarantee your fixed public variants
regardless of the cap.
Guaranteed presets
Some variants must always exist and always be servable — an OG image a crawler hits cold, a fixed social card — independent of the cap or on-demand timing. Presets are those: named, cap-exempt, and eagerly pre-generated on upload. The Presets & variants panel on every image manages the whole surface — preset toggles, queued prewarm renders, the cached-variant list with per-variant purge, and the per-image cap.
The panel opens with a width-axis tick line: every cached render plotted on the
0→maxDimension axis — violet for preset-backed variants, green for generated ones, amber for
renders the prewarm plan will generate — over gray hairlines marking every width the endpoint
could store, with everything beyond the source width shaded (no upscaling). Renders sharing a
stored width stack, and hovering a position lists each render's dimensions, fit, quality, and
format:
The Presets & variants panel: the width-axis tick line of cached renders, preset toggles (og, thumbnail), planned prewarm renders, the cached-variant list with per-variant purge, the Purge button, and the variant limit input
Define reusable templates in config. Two ship out of the box — og (1200×630 cover jpeg)
and thumbnail (160×160 cover webp, backing the admin list thumbnail) — and your entries merge
on top:
imagesPlugin({
options: {
presetTemplates: {
og: { width: 1200, height: 630, fit: 'cover', quality: 80, format: 'jpeg' },
card: { width: 600, aspectRatio: '4:3', fit: 'cover' },
},
},
})Editors toggle templates onto an image (or add a custom one) in the Presets panel; the same data seeds as plain records:
defineSeed('images', ({ file }) => [
{
_key: 'hero',
_file: file('hero.jpg'),
alt: 'Hero',
presets: [{ template: 'og' }, { name: 'wide', width: 1920, aspectRatio: '21:9', quality: 70 }],
},
])Serve one by name — the URL is short and self-documenting, and (being a finite set) needs no cap:
import { getImageUrl } from '@pro-laico/payload-images/utils/urls'
const ogUrl = getImageUrl(page.heroImage, { preset: 'og' }) // → https://<site>/api/img/<id>?preset=og&v=…Presets honor exact dimensions (no snap grid), so an og really is 1200×630. They're
generated the moment an image is created, its file replaced, or its focal point moved — so the
version a crawler needs is already there. A template is referenced by name (edit the config and
every image using it updates); a custom preset carries its own inline spec.
Revalidation
The optimized images revalidate themselves, with nothing to configure. Because every URL is
content-addressed, replacing the file or moving the focal point changes the URL, so browsers and
CDNs fetch the updated image automatically (no manual purge) while the stale variants are dropped
by the hooks. Fallback stand-ins follow the same discipline: no-store means they are never
cached anywhere, so the accurate image takes over on the very next request.
Image URLs self-bust; your page cache doesn't. A cached page keeps serving the old URL until you revalidate that route — exactly as it would for any Payload content.
With @pro-laico/payload-revalidate installed that's
automatic and atomic: fetch the image doc through an id-keyed getter and render from it —
import config from '@payload-config'
import { getPayload } from 'payload'
import { createCacheHelpers } from '@pro-laico/payload-revalidate/cache'
const { findDocByID } = createCacheHelpers(getPayload({ config }))
export async function getImage(id: string | number) {
'use cache'
return findDocByID('images', id)
}An alt or focal-point edit busts images:{id} — exactly that one entry re-materializes (with a
fresh v= token, so the binary variants re-derive), everywhere the image is used. The internal
generated-images variant cache ships custom: { revalidate: false }, so on-demand variant
writes never fire bust events. See it wired end-to-end in
examples/revalidate-sandbox.
Without the revalidate plugin, handle it however you already do (a revalidatePath /
revalidateTag in an afterChange hook, or a dynamic route).
Options
options.variantLimitnumberdefault 200Max cached variants stored per image. A per-image field; set the project default with `imagesPlugin({ options: { variantLimit } })`. Presets are exempt.
options.transform.fallbackbooleandefault trueOn a cache miss, serve an existing nearby variant instantly (`no-store`) while the exact one generates in the background.
Image metadata
One upload-time decode gives every render a blur placeholder, a color palette, alpha flags, and a subject-aware crop — no extra files, no extra requests.
Prewarming
The renders your site actually serves are pre-generated before the first visitor asks — driven by a strategy you can tune, from zero-config to fully derived.