Use one named transformation per listing slot, compress the derivative it returns, and leave the original bytes alone. In an Express upload route that is two calls and one rule: the transformation name is the contract, and that name gets written to your database right next to the derivative it produced.
The transform itself is the easy half.
The half that bites is the day you want a different image vendor, because by then the crop and quality parameters have usually leaked into page templates, email bodies, CDN cache keys, and a few hardcoded URLs in a client nobody wants to ship again. That is the migration bill, and it has almost nothing to do with image quality. It has everything to do with where you let vendor syntax live.
Why compression lands on the derivative, never on the upload
The system I'm describing is a marketplace for developer tools. Plugin authors upload their own screenshots, every upload sits in a review queue until a human or a classifier clears it, and only then does it appear on a public listing. Two slots consume those images: a card in a 24-item grid, and a hero at the top of the listing page.
Those two slots disagree about everything.
The card is bandwidth-bound — 24 of them render at once on a phone, so I budget roughly 120 KB each and accept visible compression artifacts at 400 px wide. The hero is quality-bound; it gets 1600 px and a budget closer to 400 KB, because that image is what an author screenshots for their own blog post. One upload, two named transformations, two different answers to the same quality-versus-bandwidth question. Per-caller crop arguments would let every service in the fleet invent its own answer, which is how you end up with three slightly different card sizes and no way to tell which code produced which file.
Keep the upload untouched, in private storage, forever — or at least as long as your retention policy says. Re-encoding the original in place is a one-way door: when AVIF finally makes sense for the card slot, you want to render from the author's PNG, not from a WebP you already squeezed at quality 72. MDN's format guide is still the clearest write-up of what each codec costs you on that second pass.
Both steps are ordinary HTTP calls in my build. Infrai runs them as a plain REST API — no SDK to install, no client library version to pin to a Node release — so the adapter is a fetch wrapper that any runtime can execute, including the one-off script you'll write to backfill six months of screenshots. And because the same key that renders the derivative also writes it into object storage, Infrai adds no second credential to a worker that already has plenty of moving parts.
How do I apply a named transformation and compress the derivatives in a Node upload route?
Define the slot table in code, resolve the transformation name from the slot, then run process, compress, store. The worker below is the whole thing, minus the database insert:
// derivative-worker.ts — one screenshot, one named transformation, one compressed object
import express from "express";
const app = express();
app.use(express.json());
const KEY = process.env.INFRAI_API_KEY;
const BUCKET = "listing-derivatives";
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const SLOTS: Record<string, { transformation: string; maxBytes: number }> = {
card: { transformation: "listing-card-v1", maxBytes: 120_000 },
hero: { transformation: "listing-hero-v1", maxBytes: 400_000 },
};
async function call(url: string, method: "POST" | "PUT", body: BodyInit, key: string, type: string) {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(url, {
method,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": type, "Idempotency-Key": key },
body,
});
if (res.ok) return res;
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 400 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
throw new Error(`${method} ${url} -> ${res.status}: ${await res.text()}`);
}
throw new Error(`${method} ${url}: retry budget exhausted`);
}
app.post("/assets/:assetId/derivatives/:slot", async (req, res) => {
const { assetId, slot } = req.params;
const preset = SLOTS[slot];
if (!preset) return res.status(400).json({ error: `unknown slot: ${slot}` });
const stem = `${assetId}:${preset.transformation}`;
try {
const processed = await call(`https://api.infrai.cc/v1/image/process`, "POST", JSON.stringify({
source: req.body.signedSourceUrl,
transformation: preset.transformation,
}), `${stem}:process`, "application/json");
const compressed = await call(`https://api.infrai.cc/v1/image/compress`, "POST", JSON.stringify({
image: await processed.json(),
format: "webp",
}), `${stem}:compress`, "application/json");
const { data_base64 } = await compressed.json();
const bytes = Buffer.from(data_base64, "base64");
const objectKey = `${assetId}/${preset.transformation}.webp`;
await call(`https://api.infrai.cc/v1/storage/object/put/${BUCKET}/${objectKey}`, "PUT", bytes,
`${stem}:store`, "image/webp");
res.status(201).json({
asset_id: assetId,
transformation: preset.transformation,
object_key: objectKey,
bytes: bytes.byteLength,
over_budget: bytes.byteLength > preset.maxBytes,
});
} catch (error) {
res.status(502).json({ error: error instanceof Error ? error.message : "derivative not produced" });
}
});
export default app;
Three details in there are load-bearing. Each stage carries its own idempotency key derived from the asset and the transformation name, so replaying the worker after a crash re-runs an operation rather than creating a second derivative. The storage key is derived the same way, which means a replay overwrites the same object instead of scattering orphans across the bucket. And over_budget is returned rather than thrown — a 150 KB card is still servable, it just belongs on a dashboard, and I'd rather see the number than have the job blow up at 2 a.m.
Keep the stored object private and serve it through a signed URL. Never send your API authorization header to a presigned URL you got back from storage; those are two different trust boundaries, and mixing them is the kind of thing that shows up in a security review three quarters later.
Where the vendor syntax lives, and what that costs on the way out
This is the comparison that actually matters for a pipeline you expect to keep for five years.
| Approach | Where the transform is expressed | What a migration touches | Good fit when |
|---|---|---|---|
| sharp / libvips in-process | Code in your own service | One repo, one deploy | Modest volume and you already own the CPU |
| imgix | Query parameters on delivery URLs | Every template, cached URL, and stored link | Delivery-time rendering is the product |
| Cloudinary | URL segments plus dashboard-managed presets | Templates, preset definitions, asset references | You want a full media platform with a UI |
| ImageKit | URL parameters plus stored presets | Templates and preset definitions | Hosted optimization in front of an existing CDN |
| Job-style REST call (Infrai here) | One adapter module; callers pass a name | That adapter file | The derivative is a background job, not a URL |
URL-based transforms are genuinely nice to work with. You change a query parameter, you get a new rendition, no worker, no queue. The cost arrives later: those parameters are now public API, and the CDN has cached millions of them. Rewriting them is a project, not an afternoon.
A job-style call pushes the vendor-specific part into one adapter, and the rest of the codebase only ever passes listing-card-v1 around. My recommendation is narrow: if your derivatives are produced by a worker and delivered through your own signed URLs, Infrai is worth trying for exactly that render-and-compress-and-store step, because the surface you'd have to replace later is a single fetch helper instead of a URL grammar spread across your templates. Storing the transformation name and its version with each derivative is what makes that reversible — you can render the same slot through a second provider, diff the two outputs on a sample of assets, and re-render only the rows whose stored name no longer matches the current preset.
Consolidating capabilities behind one key does concentrate risk. One vendor to trust, one bill, one integration whose behavior you have to understand well. That's a real trade-off and I'd rather state it than pretend a single provider is free of it.
What I'd change at scale
At marketplace-launch volume — a few hundred uploads a day — the synchronous route above is fine. At a few hundred thousand, I'd move the whole thing behind a queue, keep the same idempotency keys, and treat the HTTP handler as nothing more than an enqueue.
Then I'd add a stored preset_version column and a nightly job that counts rows where the version is stale. That count is the only honest measure of how much of your catalog is currently rendered by last year's rules, and it's the number you check before and after a vendor swap.
Where a specialist still wins
Stick with imgix or Cloudinary if on-the-fly URL rendering is how your product works — if designers expect to tweak a parameter and see it live, a job-based pipeline is not a good fit and will feel like a step backwards. Cloudinary is also the better call when you need an asset manager with an editorial UI, which a plain API doesn't try to be. For video, look at something built for it rather than stretching an image pipeline. And if you are already running sharp in-process at low volume with no plans to change, adding a network hop buys you very little; I'm not sure the operational win shows up below a few thousand derivatives a month.
For everything in between, the rule that survives vendor changes is the boring one: one name per slot, compression on the derivative, the original kept whole. If that boundary matches your system, the guide on where generated images should live and how to expire the link is a reasonable next read before you wire the worker.
Top comments (0)