All posts

On-demand vs pre-generated image variants: trade-offs, caching and presets

When to resize images at upload and when to resize on request, how variant caching really behaves, and how presets keep URLs maintainable.

· 6 min read

Every image pipeline has to answer one question early: do you create the sizes you need when the file is uploaded, or when someone first asks for them? Both work. They fail in different ways, cost money in different places and age differently when the design changes. This post sets out the trade-offs and shows both approaches with Dreep, along with a hybrid that suits most apps.

Pre-generated variants

With pre-generation, an upload triggers a fixed list of outputs (say a 200 px thumbnail, an 800 px card and a 1600 px full view) and each is stored as its own file. Delivery is then plain file serving.

  • Predictable: every variant exists before anyone views it, so there is no first-request delay.
  • Simple delivery: any static host or CDN can serve the files.
  • Rigid: a redesign that needs a 720 px card means a backfill job over every image you have ever stored.
  • Wasteful: you pay to generate and store sizes that are never viewed, which on a typical catalogue is most of them.
  • Slower uploads, unless you move generation to a background job and handle the gap.

On-demand variants

With on-demand transformation you store one original and describe every variant as a URL. The first request for a given URL generates it; later requests are served from cache.

  • Flexible: a new size is a code change, not a migration.
  • Efficient: only variants that are actually requested are ever generated.
  • First-request cost: the first viewer of a new variant waits for it to be generated.
  • Open to abuse unless bounded: if any width is accepted, a script can request thousands of distinct variants per image.
  • Cost follows traffic and cache behaviour rather than upload volume, which is harder to forecast.
Pre-generatedOn demand
Work happensAt uploadOn first request per variant
Design changeBackfill every imageChange the URL
StorageEvery variant, viewed or notOriginal plus viewed variants
First viewFastSlower, once per variant
Abuse riskNoneNeeds bounded parameters
Cost driverUploads × variantsDistinct variants requested

How caching changes the picture

On-demand only works if caching is reliable, so it is worth knowing exactly how it behaves rather than assuming.

  • The cache key is the asset plus its normalised transform parameters. The same parameters in a different order produce the same variant.
  • Dreep rounds width and height up to a fixed ladder of sizes (16, 32, 64, 128, 256, 384, 512, 640, 750, 828, 960, 1080, 1200, 1440, 1920, 2560, 3200 and 4000 px), snaps quality to the nearest of 50, 65, 75, 85, 95 or 100, and dpr to 1, 2 or 3. A request for width=803 is served at 828. This is what keeps the number of variants per image finite.
  • Public variants are sent with Cache-Control: public, max-age=31536000, immutable, so browsers and caches keep them for a year without revalidating.
  • Deleting an asset removes the original and every cached variant derived from it.

The ladder has a practical consequence for responsive images: pick your srcset widths from it. Asking for 400 px gets you 512 px, which is wasted bytes; asking for 384 px gets exactly 384.

JavaScriptsrcset.mjs
// On demand: store the original once, describe variants as URLs.
const photo = await dreep.upload({ file, folder: "listings/2026" });

// Widths on Dreep's breakpoint ladder, so each one maps to exactly one cached variant
const WIDTHS = [384, 640, 828, 1080, 1440, 1920];

const srcSet = WIDTHS.map(
  (width) => dreep.url(photo, { width, format: "webp" }) + " " + width + "w",
).join(", ");

// <img src={dreep.url(photo, { width: 828, format: "webp" })}
//      srcSet={srcSet} sizes="(min-width: 768px) 50vw, 100vw" alt="" />

Transformed requests count against your plan's monthly image transformation allowance (100 on Free, 10,000 on Starter, 50,000 on Pro); serving the original does not. The one-year cache means repeat views of a public variant are normally answered from cache, but budget for some misses on top of the variants you create. When the allowance runs out, transformed requests fail with LIMIT_EXCEEDED while originals keep serving, until you add a pack ($1.99 for 10,000, one-time) or upgrade.

Pre-generating with Dreep

Pass transform to an upload and it is applied before storage. The stored file is the result, and the original is not kept:

JavaScriptavatar.mjs
// Pre-generated: the transform runs before storage.
// The stored file IS the variant; the original is not kept.
const avatar = await dreep.upload({
  file,
  folder: "avatars",
  transform: { width: 512, height: 512, fit: "cover", gravity: "attention", format: "webp" },
});

This is the right call when you never want the original: avatars normalised to one size, phone photos that should never be served at 12 megapixels, or documents where you want a predictable file. It counts against the transformation allowance once, at upload. You can still request on-demand variants of the stored result, for example a 64 px avatar from the 512 px one.

Presets: naming your variants

On-demand URLs tend to sprawl. Ten components each build their own width, height and fit, and nobody knows which sizes the design actually uses. Presets fix that by giving a recipe a name. Call sites ask for card_v1, and the recipe lives in one place.

JavaScriptpresets.mjs
// Create once (Free: 3 presets, Starter: 10, Pro: 50)
await dreep.createPreset({
  key: "card_v1",
  name: "Listing card",
  transform: { width: 640, height: 480, fit: "cover", gravity: "attention", format: "webp" },
});

// Use everywhere. The SDK sends it as ?p=card_v1
dreep.url(photo, { preset: "card_v1" });

The same in Go:

Gopresets.go
_, err := client.CreatePreset(ctx, dreep.CreatePresetOptions{
    Name: "Listing card",
    Key:  "card_v1",
    Transform: &dreep.Transform{
        Width: 640, Height: 480,
        Fit:     dreep.FitCover,
        Gravity: dreep.GravityAttention,
        Format:  dreep.FormatWebP,
    },
})
if err != nil {
    log.Fatal(err)
}

card := client.URL(photo.ID, &dreep.Transform{Preset: "card_v1"})

Parameters on the URL override the preset, so ?p=card_v1&format=png returns a PNG. Preset keys use lowercase letters, numbers and underscores.

Worked example: a listings page

Assumptions: 1,500 new listings a month, eight photos each, three layouts (a 384 px grid card, an 828 px detail view and a 1920 px lightbox), each served in WebP only.

  • Pre-generated: 1,500 × 8 × 3 = 36,000 variants generated every month, whether or not anyone opens the lightbox. Each later redesign adds a backfill over the whole catalogue.
  • On demand: grid cards are viewed for nearly every photo, but most buyers open only a few listings and fewer open the lightbox. If 100% of cards, 40% of detail views and 15% of lightbox images are ever requested, that is 12,000 × (1 + 0.4 + 0.15) = 18,600 variants, plus cache misses.

On Dreep, the on-demand case fits within Pro's 50,000 monthly image transformations with room for misses, while the pre-generated case would also fit but spends the allowance on images nobody sees. The bigger difference is the next redesign: on demand, it costs nothing until users view the new size.

The hybrid most apps end up with

  • Normalise on upload where the original has no value: avatars, logos, oversized phone photos. Use an upload transform.
  • Keep originals for anything that appears in more than one layout: listings, articles, galleries. Serve on demand.
  • Name every layout's variant as a preset, versioned, so a redesign is a new preset rather than a code hunt.
  • Build srcset from the breakpoint ladder so each width maps to exactly one cached file.
  • Warm the variants that matter most, such as the listing card, by requesting them once after upload if the first view must be fast.

Every parameter is listed in the image transformations docs and presets in the presets guide. For the delivery side, see the image CDN API page.

Related

Try the examples against your own project

The Free plan includes 1 GB of storage, 100 image and 20 video transformations a month, which is enough to run every snippet in these posts.