# Prewarming

URL: /docs/plugins/payload-images/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.

A cold cache miss is just the [slowest possible response](/docs/plugins/payload-images/caching#the-transform-pipeline) —
a full Sharp render. Prewarming makes misses rare: it generates the variants a site will actually
request before anyone asks, as deferred Payload Jobs. A **strategy** declares both halves of that:
**what to warm** and **when it runs**.

Prewarm composes with the [nearby-quality fallback](/docs/plugins/payload-images/caching#nearby-quality-fallback):
prewarm makes misses rare, the fallback makes the residual ones instant.

## What gets warmed

See the plan for a single image — every tick is a width the endpoint could store, hover one for
its renders:

Targets derive per image in priority order — the budget cuts from the tail:

1. **Built-ins** (`builtIns`) — the three render slots the plugin itself emits on every doc read:
   `src` (at `min(sourceWidth, 1280)`), `thumbnailURL` (160² at `transform.defaultQuality`), and
   `placeholderURL` (32px, q40).
2. **Seeds** (`seeds`) — hand-declared learned profiles, for the renders the
   plugin can't know about: the crops *your pages* declare. A hero read of
   `.aspectRatio('16:9').quality(80)` is a render nothing else on this list produces — traffic
   would teach it eventually (item 3), but a seed of `{ aspectRatio: '16:9', quality: 80 }` warms
   it from the very first upload, before any visitor pays the cold render. Defaults to one square
   seed, `{ aspectRatio: '1:1', quality: 80 }` — declaring your own replaces it (declare as many
   as you like; an explicit `[]` disables seeding).
3. **Learned profiles** (`learned`) — the same thing, observed instead of declared. Every transform
   response logs `(ratio, fit, quality, format, width)` to the hidden `image-render-profiles`
   collection (in-memory buffer with a timed flush — near-zero cost on the serve path). Each
   profile warms its **top 4 observed widths** by hit count; profiles with no traffic for 30 days
   are ignored by warm plans (the rows are kept, so returning traffic revives them).

Units that don't carry their own widths inherit them in a chain: a seed without `widths` first
adopts a matching observed profile's top widths, then falls back to the strategy's **width axis**
(`widths`), as do learned profiles without a histogram. The axis: `{ every: n }` is every Nth
reachable width (the default is `{ every: 5 }` — every \~250px on the default 50px grid, a skeleton
the [fallback](/docs/plugins/payload-images/caching#nearby-quality-fallback) fills between),
`'srcset'` is exactly the widths `srcset` emits (the default with an array `pixelStep`, whose
ladder is small and exact), and an explicit `number[]` is those widths.

Built-ins and seeds expand into `formats` (default `['webp']`, plus `'avif'` when
`transform.preferAvif` is on); a learned profile that observed a concrete format warms only that
format, and only `auto` observations expand. Every target dedupes against already-cached variants
and replays through the endpoint's own parser — so a warmed variant is a **byte-identical cache
hit** for organic traffic. The plan is capped by `maxVariantsPerImage` (default 32) — lower-priority units lose
budget first, and a unit that doesn't fit **spreads its widths evenly** across its range (half the
budget warms every other width) rather than losing its large end. A capped plan reports
`truncated` on the status endpoint and the admin panel.

## When it runs

Enqueueing work and running it are separate: saves **enqueue** one `imagesPrewarm` job per image,
and a runner drains the queue. **By default that runner exists on any host.** `autoRun` (on unless
you set it to `false`) keeps three runners alive, all on the plugin-owned `images-prewarm` queue:

- **A 5-minute in-process cron** (`'*/5 * * * *'`, 50 jobs per firing — one image each, so up to
  10 images a minute) — the workhorse on any long-lived process, `next dev` included.
- **A post-upload kick** — every save that enqueues also runs the queue right after its response
  (via `after()`, so the upload never waits on sharp). This is what makes prewarm work on
  **serverless**, where in-process crons never fire: the upload itself carries its own runner.
  The kick is request-scoped by design — a save from a **CLI script or seed** enqueues but does
  not kick (a detached runner would race the script's exit and tear writes mid-persist); those
  jobs wait as a queued backlog for the next long-lived process's cron, kick, or drain.
- **An image-traffic drain** — at most once a minute per process, serving an image also works
  through up to 5 queued jobs after the response. A steady backstop for anything the kick missed
  (a killed instance, a CLI-enqueued backlog): the queue drains promptly, but never all at once.

The request-scoped runners are budgeted and self-healing. A kick pulls jobs one at a time and
stops starting new ones 290 seconds after it was scheduled — just under the tightest serverless
function ceiling (Vercel Hobby's 300s) — so a heavy batch ends cleanly between jobs instead of
being killed mid-encode; the remainder stays queued for the next kick or drain. And every kick or
drain first returns jobs stuck `processing` for 5+ minutes without a heartbeat (a runner that died
mid-job) to the queue — as does the admin panel's status poll — so a crashed run can never strand
an image as forever-"running".

The dedicated queue means all three can only ever execute the plugin's own jobs, never your app's.

**Enqueued by:**

- **Every save** (`onUpload`, default on) — image created, file replaced, or focal edited: exactly
  the purge triggers, so invalidation and re-warming can't drift apart. A 30-second delay coalesces
  rapid saves and duplicates dedupe. `onUpload: false` is batch-only mode, for imports that
  shouldn't fan out one job per image.
- **The CLI**, for a whole library: `payload images:prewarm` (cap a run with `--limit 200`).

**Run by:**

- **The default `autoRun` runners** — the cron (appended to `config.jobs.autoRun`, composing with
  your own entries, never replacing them), the post-upload kick, and the image-traffic drain. Tune
  the cadence and throughput, or opt out of all three with `autoRun: false` — that means *you* run
  the jobs:

```ts
imagesPlugin({
  options: {
    prewarm: {
      strategy: {
        autoRun: '0 3 * * *', // nightly instead of every 5 minutes
        autoRunLimit: 200,    // jobs (images) per firing
      },
    },
  },
})
```

- **Your existing jobs runner** — `payload.jobs.run()`, `payload jobs:run`, or a scheduled request
  to `/api/payload-jobs/run`. All three are Payload's own machinery —
  [Payload's Queues docs](https://payloadcms.com/docs/jobs-queue/queues) cover them in full.
- **`payload images:prewarm --now`** — generates inline as a build/deploy step; needs no runner.

**On serverless, only the cron is dead weight** — the kick and drain still run everything, so no
wiring is required (the plugin logs a one-line note at init so you know which paths are live). If
you want scheduled draining on top — e.g. to chew through a big CLI backlog on a site with little
traffic — point a [Vercel Cron](https://vercel.com/docs/cron-jobs) at
`/api/payload-jobs/run?queue=images-prewarm`. Cron strings are the standard five-field
expressions — validate one at [crontab.guru](https://crontab.guru/).

Retries and re-runs are always safe — every run recomputes the plan and only generates what's
still missing.

> A failed or absent jobs setup never blocks an upload — enqueueing is best-effort, and the site
> works exactly as if prewarm weren't there: variants generate on first request, the
> [fallback](/docs/plugins/payload-images/caching#nearby-quality-fallback) covers the gap.

## Options

```ts
imagesPlugin()                                // prewarm is ON by default — the 'default' strategy
imagesPlugin({ options: { prewarm: false } }) // the opt-out
```

> Being on registers the hidden `image-render-profiles` collection — a **schema change**, so run
> `payload generate:types` after upgrading. `prewarm: false` leaves your schema untouched.

The **Reference** tab is the interactive view (`strategy` expands to its nested options);
**TypeScript** is the same shape in code, every option at its default.

**Reference**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `options.prewarm` | `false \| PrewarmOptions` | `{}` | Pre-generate the renders the site actually serves for new images. On by default; enabling adds a hidden collection — a schema change. Pass an object to tune it, or false to opt out. |
| `options.prewarm.strategy` | `'default' \| PrewarmStrategyConfig` | `'default'` | What a prewarm run does and when it runs. 'default' is the empty config written out: built-ins, a square 1:1 q80 seed at the derived widths, learned profiles, enqueue on upload, and the autoRun runners (5-minute cron + upload kick + traffic drain) on the images-prewarm queue. |
| `options.prewarm.strategy.widths` | `'srcset' \| { every: number } \| number[]` | `{ every: 5 } · 'srcset' with an array pixelStep` | The width axis seeds and learned profiles warm at when they don't carry their own widths: every Nth reachable width (sampled from the top so the largest width always survives), exactly what srcset emits, or an explicit list. The default adapts to pixelStep: an every-5th skeleton on the numeric grid, the exact ladder for an array. |
| `options.prewarm.strategy.builtIns` | `boolean` | `true` | Warm the three renders every read implies: src, thumbnailURL, placeholderURL. |
| `options.prewarm.strategy.learned` | `boolean` | `true` | Warm learned render profiles at their top 4 observed widths (by hit count). |
| `options.prewarm.strategy.seeds` | `RenderProfileSeed[]` | `[{ aspectRatio: '1:1', quality: 80 }]` | Hand-declared learned profiles: the crops your pages declare, warmed before any traffic has taught the system. Each seed takes aspectRatio, fit, quality, and widths. Declaring your own replaces the default square seed; an explicit [] disables seeding. |
| `options.prewarm.strategy.onUpload` | `boolean` | `true` | Enqueue a prewarm job when an image is created, its file replaced, or its focal edited. false is batch-only mode — the admin button, CLI, and cron paths stay. |
| `options.prewarm.strategy.autoRun` | `string \| false` | `'*/5 * * * *'` | Whether the plugin runs its own jobs. The cron drives the in-process runner on long-lived processes (wired into config.jobs.autoRun, composing with your entries); on serverless — where in-process crons never fire — the same knob keeps the post-upload kick and the image-traffic drain alive instead. false turns all three off: you run the jobs yourself. |
| `options.prewarm.strategy.autoRunLimit` | `number` | `50` | Max jobs (one image each) the autoRun cron — and the post-upload kick — executes per firing; the default works out to 10 images a minute on the cron. |
| `options.prewarm.strategy.queue` | `string` | `'images-prewarm'` | The Payload Jobs queue prewarm jobs land on. Plugin-owned by default, so the cron and the admin Run-now kick can never execute the app's own jobs. |
| `options.prewarm.formats` | `OutputFormat[]` | `['webp']` | Formats to warm. 'avif' is added automatically when options.transform.preferAvif is set. |
| `options.prewarm.maxVariantsPerImage` | `number` | `32` | Cap on variants prewarmed per image. A capped plan reports truncated on the status endpoint. |

**TypeScript**

```ts
import { imagesPlugin } from '@pro-laico/payload-images'

// Every option at its default. This is the zero-config behaviour, written out.
imagesPlugin({
  options: {
    prewarm: {
      // or false to opt out; `strategy: 'default'` ≡ `strategy: {}` ≡ this, written out:
      strategy: {
        widths: { every: 5 }, // every 5th reachable width — or 'srcset' (the default with an array pixelStep), or an explicit number[]
        builtIns: true,   // warm src / thumbnailURL / placeholderURL
        learned: true,    // warm observed render profiles
        seeds: [{ aspectRatio: '1:1', quality: 80 }], // declaring your own replaces this default; [] disables
        onUpload: true,   // enqueue on create / file replace / focal edit
        autoRun: '*/5 * * * *', // the plugin runs its own jobs: this cron on long-lived processes, upload kicks + traffic drains on serverless — false turns all three off
        autoRunLimit: 50, // jobs (images) per cron firing / upload kick — up to 10 images a minute on the cron
        queue: 'images-prewarm', // plugin-owned, so the cron only ever runs OUR jobs
      },
      formats: ['webp'],  // +'avif' automatically when transform.preferAvif
      maxVariantsPerImage: 32,
    },
  },
})
```
