Pick the least complex option that still gives every storefront slot its own frame: one content-aware smart crop per declared aspect ratio, stored as a named derivative of the source image. A single hero master scaled by CSS looks fine in a 2560x960 desktop banner and beheads the model in the 3:4 mobile portrait slot, so the one-image-many-slots shortcut is free right up until somebody opens the store on a phone.
That's the recommendation. The interesting part is what it costs to keep.
| Approach | How you call it | What it does to storage and cache | Where it hurts |
|---|---|---|---|
One master plus CSS object-fit
|
nothing to call | 1 object per hero, smallest footprint | the subject drifts out of frame on every slot that is not the master ratio |
| URL-driven CDN (imgix, Cloudflare Images) | query params on a source URL | unbounded URL space, cached at the edge | every new parameter combination mints another cache object |
| Asset platform (Cloudinary) | SDK plus named transformations | derivatives managed for you, in their bucket | the transformation names become a dialect you maintain |
| Self-run libvips or thumbor | in-process, or your own worker | you own the bucket and the eviction policy | you also own decoders, memory ceilings and autoscaling |
| Plain HTTP crop API (Infrai) | one REST API call per slot, one key for the whole backend | bounded, N derivatives per source, countable and deletable | a general backend API rather than an asset manager with a review UI |
Take the bottom two rows when the slot set is finite and known at build time, which it is for most storefronts: five slots, declared in code, versioned alongside the theme. Reach for a URL-driven CDN when the slot set is genuinely open-ended. Everything below is about the two criteria that decide which side of that line you land on, which are how many derivatives each source produces and whether you can ever delete them.
The multiplier is what costs you, not the pixels
Storage price sheets push you to think in gigabytes. Wrong unit. For hero images the number that moves the bill is the count of distinct objects, because each one is a row in your assets table, an entry in your CDN cache namespace, and something that has to be evicted when the tenant swaps the picture.
Do the arithmetic before you design the pipeline. Five declared slots — desktop banner, tablet, mobile portrait, collection card, social preview — times two DPR variants, times three formats (AVIF, WebP, JPEG) is 30 derivatives per 4 MB source. A B2B storefront product with 12,000 tenant heroes is therefore holding 360,000 objects before anybody uploads a second season, and the 2x AVIF tablet variant of a below-the-fold collection card might be requested twice a month. It never stays warm at the edge. Every one of those requests is an origin read, and the object occupies storage for the entire stretch of time nobody is reading it.
So cut the multiplier where cutting is cheapest. The slot axis is the one you materialize, because a smart crop is a decision about content — which region holds the product, the face, the logo — and you want that decision made once and reused. DPR and format are pure resampling off an already-cropped derivative, so generate them on first request and let the cache keep them.
Five is a number you can audit. Thirty isn't.
Should I pre-render smart crops for every storefront hero slot, or crop desktop and mobile on demand?
Pre-render the crops. Render the resizes on demand. The two operations look similar in an API listing and behave nothing alike.
A crop is content-dependent and occasionally wrong, which makes it worth a human glance before it reaches a live storefront. Doing it lazily means the first visitor to hit a new slot is also the first person to see an unreviewed frame of a handbag with the handle sliced off. Doing it at publish time gives you something to put on an approval screen.
A resize is deterministic. Nobody reviews those.
The second criterion is deletion, and it's the one teams skip. Every derivative needs to carry its source id, its slot name and the version of the crop policy that produced it, because in eighteen months you'll want to re-crop the catalogue with a better model and you'll need to know precisely which objects to evict. Write that row before anything downstream caches a byte. If the only place your derivative ids exist is the URL space of a CDN, what you've got isn't an asset pipeline.
Infrai fits this shape when the crop worker is one small piece of a backend that already talks to five other services, because it exposes smart cropping as a plain REST call behind one key that also covers the storage and queue traffic around it, so a five-slot worker adds no client library and no second vendor to onboard. The catch is the last column of that table. There is no asset browser or review console in front of it, so the approval screen is yours to build, and teams that want the console more than the API should buy the console.
The worker, end to end
Five slots, one call each, an idempotency key that pins each derivative to a crop policy version, and a lineage row written before anything is cached.
// Run: INFRAI_API_ORIGIN=... INFRAI_API_KEY=... node --experimental-strip-types crop-hero.ts
const ORIGIN = process.env.INFRAI_API_ORIGIN; // API origin, ending in /v1
const KEY = process.env.INFRAI_API_KEY;
if (!ORIGIN || !KEY) throw new Error("set INFRAI_API_ORIGIN and INFRAI_API_KEY");
const CROP_POLICY = "2026-08-a";
const SLOTS = [
{ slot: "desktop-banner", width: 2560, height: 960 },
{ slot: "tablet", width: 1536, height: 864 },
{ slot: "mobile-portrait", width: 828, height: 1104 },
{ slot: "collection-card", width: 800, height: 800 },
{ slot: "social-preview", width: 1200, height: 630 },
];
async function post(path: string, body: unknown, idempotencyKey: string) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${ORIGIN}${path}`, {
method: "POST",
headers: {
authorization: `Bearer ${KEY}`,
"content-type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
const text = await res.text();
if (!res.ok) throw new Error(`${res.status} on ${path}: ${text.slice(0, 200)}`);
return JSON.parse(text) as { id: string };
}
throw new Error(`rate limited on ${path} after 5 attempts`);
}
export async function cropHero(sourceId: string) {
const lineage = [];
for (const { slot, width, height } of SLOTS) {
const derivative = await post(
"/image/smart_crop",
{ image_id: sourceId, width, height },
`hero-${sourceId}-${CROP_POLICY}-${slot}`,
);
lineage.push({ source_id: sourceId, slot, derivative_id: derivative.id, policy: CROP_POLICY });
}
return lineage; // persist this before you let a CDN see any of it
}
The idempotency key carries the weight here. It's derived from the source id, the crop policy version and the slot name, so a redeployed worker, a duplicated queue message and a nervous operator running the job again all converge on the same five derivatives instead of quietly tripling your object count. Bump CROP_POLICY and you deliberately get a new set, with the old one still addressable until you evict it.
Notice what the worker does not do. It never fans out DPR or format variants; POST /v1/image/resize handles those at request time from the derivative that POST /v1/image/smart_crop already produced, behind whatever cache sits in front of your images. That is the whole reason the count stays at five per hero.
When a rendering CDN is the better call
All of the above assumes a small, declared slot set. Plenty of storefronts aren't built that way.
If merchandisers can invent layouts in a page builder, the slot set is effectively unbounded and pre-rendering is the wrong shape, since you'd be materializing crops for aspect ratios nobody ever requests. Stick with a URL-driven renderer such as imgix or Cloudflare Images, accept the unbounded cache namespace as the price of flexibility, and put signed parameters in front of it so a crawler can't mint a million variants on your bill. ImageKit sits in the same family with more storage opinions attached.
If per-transformation billing is what dominates your spend, run libvips yourself and pay in decoder patching, memory ceilings and a queue somebody has to operate. thumbor is the packaged version of that same trade.
And if what you actually need is moderation, versioning and a browsable library with roles, a platform like Cloudinary is a better fit than any plain API, mine included in that judgement. Not suitable for a five-slot storefront, in my view — though I'm not sure that generalizes, and if your merchandising team lives inside an asset browser then the console is the product you're buying.
One thing survives every option on that list. Whoever renders the crops, the lineage row belongs to you.
Further reading
- MDN, Media formats guide — https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats
- MDN, the
<picture>element — https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture - imgix rendering API reference — https://docs.imgix.com/apis/rendering
- Cloudflare Images documentation — https://developers.cloudflare.com/images/
- libvips API reference — https://www.libvips.org/API/current/
- Cloudinary resizing and cropping — https://cloudinary.com/documentation/resizing_and_cropping
Top comments (0)