# Caching

URL: /docs/plugins/payload-images/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](/docs/plugins/payload-images/prewarming) and the
nearby-quality fallback.

## The transform pipeline

The [transform endpoint](/docs/plugins/payload-images/image-urls) 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](/docs/plugins/payload-images/prewarming) learns from, so
the renders a site actually uses don't stay cold: new images get them pre-generated, and
[presets](#guaranteed-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
  and `POST /api/img/purge/:id` clear them on demand.
- **Browser/CDN cache.** Responses are immutable and each URL carries the `v` token, 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 served `private` with no shared CDN caching. The
  purge endpoint gates on `options.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 `pixelStep` grid (or, for an
  array `pixelStep`, to the grid + the ladder's exact widths) and quality buckets to a small set,
  both clamped to `maxDimension`, so a caller can't spin up unbounded variants with `w=1,2,3,…`.
  Output never upscales past the source.
- **Per-image variant cap.** Each image stores at most `variantLimit` cached 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](#guaranteed-presets) are exempt. See the
  [cap details](#the-variant-cap) below.
- **Bounded work.** `maxInputPixels` caps 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](#nearby-quality-fallback) 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](#guaranteed-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](/screenshots/presets-n-variants.png)

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:

```ts
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:

```ts
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:

```tsx
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`](/docs/plugins/payload-revalidate) installed that's
automatic and atomic: fetch the image doc through an id-keyed getter and render from it —

```ts
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`](https://github.com/pro-laico/payload-plugins/tree/main/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

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `options.variantLimit` | `number` | `200` | Max cached variants stored per image. A per-image field; set the project default with `imagesPlugin({ options: { variantLimit } })`. Presets are exempt. |
| `options.transform.fallback` | `boolean` | `true` | On a cache miss, serve an existing nearby variant instantly (`no-store`) while the exact one generates in the background. |
