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 — 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:
Violet values are the plugin defaults. Assumes a 3:2 source.
Targets derive per image in priority order — the budget cuts from the tail:
- Built-ins (
builtIns) — the three render slots the plugin itself emits on every doc read:src(atmin(sourceWidth, 1280)),thumbnailURL(160² attransform.defaultQuality), andplaceholderURL(32px, q40). - 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). - Learned profiles (
learned) — the same thing, observed instead of declared. Every transform response logs(ratio, fit, quality, format, width)to the hiddenimage-render-profilescollection (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 devincluded. - 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: falseis 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
autoRunrunners — the cron (appended toconfig.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 withautoRun: 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 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 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-outBeing 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 pixelStepThe 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 trueWarm the three renders every read implies: src, thumbnailURL, placeholderURL.
learnedbooleandefault trueWarm 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 trueEnqueue 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 50Max 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 32Cap 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,
},
},
})