Payload Plugins
Pluginspayload-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.

For AI / LLMs: View Markdown

A cold cache miss is just the slowest possible response — 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: 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:

0px4096px (maxDimension)
built-insseedslearnedcut by the cappossible, on-demand onlybeyond the sourcestacked segments = multiple renders at one width
prewarmed
13 variants / cap 32
3 built-in10 seed
reachable widths
48
for this source — the ticks left of the shade
storable variants
2,880
widths × qualities × fits × formats, at ONE ratio — each declared ratio multiplies again
pixelStep
strategy.widths: { every: n }
seeds

Violet values are the plugin defaults. Assumes a 3:2 source.

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 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:
imagesPlugin({
  options: {
    prewarm: {
      strategy: {
        autoRun: '0 3 * * *', // nightly instead of every 5 minutes
        autoRunLimit: 200,    // jobs (images) per firing
      },
    },
  },
})
  • Your existing jobs runnerpayload.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 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 at /api/payload-jobs/run?queue=images-prewarm. Cron strings are the standard five-field expressions — validate one at 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 covers the gap.

Options

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.

options.prewarmfalse | PrewarmOptionsdefault {}

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.

strategy'default' | PrewarmStrategyConfigdefault '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.

widths'srcset' | { every: number } | number[]default { 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.

builtInsbooleandefault true

Warm the three renders every read implies: src, thumbnailURL, placeholderURL.

learnedbooleandefault true

Warm learned render profiles at their top 4 observed widths (by hit count).

seedsRenderProfileSeed[]default [{ 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.

onUploadbooleandefault 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.

autoRunstring | falsedefault '*/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.

autoRunLimitnumberdefault 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.

queuestringdefault '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.

formatsOutputFormat[]default ['webp']

Formats to warm. 'avif' is added automatically when options.transform.preferAvif is set.

maxVariantsPerImagenumberdefault 32

Cap on variants prewarmed per image. A capped plan reports truncated on the status endpoint.

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,
    },
  },
})

On this page