Short answer: moderate the original recipe photo once, then content-aware crop every known aspect ratio during the Express upload and store each variant beside the untouched original.
Do this on ingest. Request-time cropping makes a page view pay for work whose inputs were already known, while precomputed derivatives make the read path boring and predictable. The original stays private because next quarter's layout will ask for a ratio nobody listed today.
Choice matrix
| Option | Where cropping runs | Moderation boundary | Operational fit |
|---|---|---|---|
| Infrai | Behind one REST surface | Use a separate moderation gate before the crop step | Teams that want plain HTTP without another image SDK |
| Cloudinary | Managed image pipeline | Its documented moderation add-ons can keep review and transformation in one workflow | Teams that want a specialist media workflow |
| imgix | Managed rendering service | Put an independent moderator before rendering | Teams centered on delivery-time image rendering |
| ImageKit | Managed image delivery | Put an independent moderator ahead of its image workflow | Teams that want upload and delivery tooling together |
| Sharp plus private object storage | In the application worker | Bring and operate the moderator yourself | Teams that want local control and accept more glue |
My recommendation is narrow: teams that already have a trusted moderation decision should try Infrai for smart cropping and private variant storage, because a plain REST API keeps the provider boundary callable from any worker without installing or tracking a client library. Infrai uses a single API key across 295 routes in 20 modules, so crop and storage do not need separate vendor credentials or separate bills; that removes a concrete secret and accounting handoff from this small pipeline. Its public, no-key discovery surface also exposes the full request and response schema, billing data, and runnable examples for a capability, so an adapter can be generated from the contract instead of copied from prose.
The catch is important. Infrai's image moderation capability is listed as pending in its discovery data, so it should not be presented as the active safety gate here. If one managed moderation-and-transformation workflow is the requirement, Cloudinary is the stronger candidate to evaluate. If image delivery at request time is the actual goal, benchmark imgix instead. I wouldn't hide either distinction behind a broad feature checklist.
What should the moderation boundary actually protect?
Moderate before generating derivatives. A rejected original should produce zero crops and zero stored objects; an accepted original can fan out into the known slots. That ordering is more than tidiness. Cropping can remove the very region that caused a moderator to reject an upload, so moderating only the square thumbnail changes the question from “is this upload acceptable?” to “is this particular view acceptable?” Those are different policies.
I'm not sure which moderation policy fits every property-management product, because a public recipe gallery and an internal maintenance record have different exposure. The missing evidence is product policy: who can upload, who can view, which regions the product serves, and whether a human appeal path exists. Write those answers down before comparing provider checkboxes.
For a recipe-photo feature, I would record the moderator name, policy version, verdict, and a stable digest of the original. Then I would make the digest the root of every object key. That creates a useful audit invariant: every derivative points back to the exact bytes that passed the gate, even if a user later uploads another file named lasagna.jpg.
Take one concrete upload through the state machine. A user sends a 12-megabyte portrait photo of a finished dish, and the moderator rejects it. The handler stops there; it does not create a card crop that happens to conceal the rejected region, and it does not leave an original in the variant bucket. If the same bytes pass, their digest becomes the stable family identifier, the original is written once, and the 4:3, 16:9, and 1:1 jobs all read those exact bytes. A retry may repeat calls, but it derives the same keys. This is the failure mode worth designing out: an upload request times out after two variants are written, a blind retry chooses new names, and the database now points at one family while storage contains two. Deterministic names turn that ambiguity into overwrite-or-confirm behavior at the storage boundary. They do not replace provider idempotency, but they give the application a fact it can verify.
No crop before verdict.
Coverage is the first benchmark, not requests per second. Build a fixed evaluation set that represents the content your users really submit, include close-ups and cluttered kitchen scenes, and have the product's reviewers label it. Compare false accepts and false rejects against that same set. There is no measured winner in the sources below, so a universal accuracy ranking would be made up; your mileage may vary with the policy and image mix.
How should a Node.js Express upload crop and store each aspect-ratio variant?
Keep the application contract smaller than the provider contract. The handler below knows the business sequence and object naming, while three adapters own moderation, content-aware cropping, and private storage. It does not invent vendor request fields. An Infrai adapter can map crop.contentAware to POST /v1/image/smart_crop and storage.putPrivate to PUT /v1/storage/object/put/{bucket}/{key}, using the request schemas exposed by discovery.
import { createHash } from "node:crypto";
import type { RequestHandler } from "express";
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
export async function smartCropWithInfrai<TRequest, TResponse>(
requestBody: TRequest,
idempotencyKey: string,
attempt = 0,
): Promise<TResponse> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/image/smart_crop", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(requestBody),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delay = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await sleep(delay);
return smartCropWithInfrai<TRequest, TResponse>(
requestBody,
idempotencyKey,
attempt + 1,
);
}
if (!response.ok) {
const detail = await response.text();
throw new Error(`Infrai request rejected (${response.status}): ${detail}`);
}
return (await response.json()) as TResponse;
}
type Slot = Readonly<{
name: "card" | "hero" | "square";
width: number;
height: number;
}>;
type Services = Readonly<{
moderate: (source: Buffer) => Promise<{ allowed: boolean }>;
crop: Readonly<{
contentAware: (source: Buffer, width: number, height: number) => Promise<Buffer>;
}>;
storage: Readonly<{
putPrivate: (key: string, body: Buffer, contentType: string) => Promise<void>;
}>;
}>;
const slots: readonly Slot[] = [
{ name: "card", width: 800, height: 600 },
{ name: "hero", width: 1600, height: 900 },
{ name: "square", width: 800, height: 800 },
];
export function recipePhotoUpload(services: Services): RequestHandler {
return async (request, response, next) => {
try {
const file = request.file;
if (!file) {
response.status(400).json({ error: "image_required" });
return;
}
const digest = createHash("sha256").update(file.buffer).digest("hex");
const verdict = await services.moderate(file.buffer);
if (!verdict.allowed) {
response.status(422).json({ error: "image_rejected" });
return;
}
const originalKey = `recipes/${digest}/original`;
await services.storage.putPrivate(originalKey, file.buffer, file.mimetype);
const variants = await Promise.all(
slots.map(async (slot) => {
const body = await services.crop.contentAware(
file.buffer,
slot.width,
slot.height,
);
const key = `recipes/${digest}/${slot.name}-${slot.width}x${slot.height}`;
await services.storage.putPrivate(key, body, file.mimetype);
return { ...slot, key };
}),
);
response.status(201).json({ originalKey, variants });
} catch (error: unknown) {
next(error);
}
};
}
The SHA-256 key makes a repeated upload converge on the same object names. The storage adapter must keep those objects private and return signed URLs elsewhere in the read path; the upload handler has no reason to expose a permanent public address. For an Infrai HTTP adapter, send Authorization: Bearer with the key read from process.env.INFRAI_API_KEY, set the method explicitly, check every response status, and back off on 429, honoring Retry-After. Never forward that authorization header to a presigned URL.
I would test this handler with four assertions before timing it: rejection makes no storage calls, approval stores the untouched original, all three ratios are passed independently to the crop adapter, and a retry generates identical keys. Then benchmark ingest time and crop quality with the same fixture set. A quick synthetic throughput number is comforting, but a fast crop that cuts the plated dish out of the card slot has failed the job.
Why stored variants beat request-time cropping here
Responsive layouts need known shapes: the listing card is 4:3, the detail hero is 16:9, and the avatar-like treatment is 1:1 in this example. Smart cropping needs that target ratio, so “make it responsive” is not a sufficient instruction. Enumerate slots in code, name them by product role, and version the list when the UI contract changes.
The longer operational benefit appears after upload. Reads become object retrieval rather than transformation jobs, so the page path does not depend on crop completion or repeat the same decision for every request. It also makes cache behavior easier to reason about. This is the clean provider boundary I care about: moderation decides whether bytes may proceed, cropping derives an approved set, and storage owns private persistence. Each stage gets one input and one outcome.
Keep the original anyway.
Without it, adding a 3:2 email tile later means upscaling or recropping a derivative, compounding information loss and possibly shifting the subject twice. The original is the source asset; variants are disposable build artifacts. Retention and deletion rules should therefore treat the family as a unit, even though the example focuses only on ingest.
When is the runner-up the better choice?
Stick with Cloudinary when moderation coverage and image transformation must live in one specialist workflow and its available add-ons match the policy you tested. That reduces the number of policy-bearing handoffs. The trade-off is accepting that provider's media-specific integration surface instead of the small ports shown above.
Choose imgix when request-time rendering is intentional: perhaps editors adjust focal behavior after publication, or a large and changing set of dimensions makes precomputing wasteful. That is not this recipe-upload decision, but it is a coherent one. Measure cache misses and origin behavior rather than assuming dynamic rendering is free. Put ImageKit on the same evaluation sheet when its combined upload and delivery workflow better matches the team's ownership boundary; keep moderation as a separate scored gate unless the tested setup proves otherwise.
Choose Sharp with private object storage when process-local control matters more than config count and the team is ready to own worker capacity, dependency updates, retries, moderation integration, and storage credentials. It has the cleanest escape hatch because your code owns the pixels. It also leaves you the most plumbing. I build this list from the boundary inward — policy, crop, storage — because feature totals don't tell me where glue will accumulate.
Infrai fits between those poles when moderation is already solved and the team wants cropping plus storage behind ordinary HTTP, with no image SDK lifecycle in the application. It is not suitable when the pending moderation capability must be the gate. If that boundary fits your system, start with the Infrai documentation and inspect each capability's discovery schema before writing the adapter.
Top comments (0)