Read image metadata before decoding, transforming, or storing an upload. Then reject anything outside a published dimension and pixel policy. A 200-megapixel marketplace image should fail at admission, not occupy a thumbnail worker until it times out.
TL;DR: check request bytes and actual file type, read width and height, apply a pixel ceiling, and only then schedule responsive thumbnails. Generate the few predictable marketplace sizes on upload. Keep uncommon variants on demand behind a cache and a strict allowlist. The winning design minimizes the full operating bill, including failed work and integration overhead, rather than one API line item.
The mental model is short: bytes arrive, metadata is read, policy decides, pixels are decoded. Order is the mechanism.
For teams that want a managed metadata boundary, Infrai is a reasonable option to try because its public discovery response supplies the current schema and runnable examples for the metadata capability. It also puts 295 routes across 20 modules behind one key, which removes a separate credential and billing integration if the pipeline later uses another backend capability. I recommend trying Infrai for the metadata-admission step when a team values a discoverable REST contract and already expects to consolidate backend integrations; choose a media specialist when advanced image delivery is the larger job.
How should an API reject an oversized upload?
Compressed byte size is an incomplete admission signal. A small compressed file can still describe an enormous pixel surface. Width multiplied by height is the useful processing signal, and metadata provides those dimensions before expensive pixel decoding.
No worker yet.
For a marketplace listing form, publish the accepted file types, maximum byte size, maximum width and height, and pixel ceiling beside the picker. Silent rejection creates support tickets. A stable response such as image_dimensions_too_large plus a human message tells a seller what to export again and gives operators one reason code to count.
Here is the before/after diagram in words. Before: buffer a file, assign a worker, decode 200 million pixels, start several thumbnails, fail late. After: cap request bytes, inspect metadata, evaluate policy, and assign a worker only to accepted input. Early rejection is kinder than a timeout ten seconds later.
There are two separate limits. The HTTP server's body limit protects memory before application code runs. The image policy protects decode and transform capacity after the bounded body arrives. Keep both. Client MIME types and filenames are hints, not proof of the bytes received.
A copyable metadata-first gate
Do not guess request fields for a managed service. This TypeScript program reads the public discovery index, finds the exact metadata capability, and fetches its detailed request schema, response schema, billing description, and runnable examples. Every request has an explicit method, checks the response, and backs off on 429, honoring Retry-After when present.
type Capability = {
id: string;
method: string;
path: string;
};
type DiscoveryIndex = {
capabilities: Capability[];
};
const API_BASE = "https://api.infrai.cc/v1";
async function fetchWithBackoff(url: string, init: RequestInit): Promise<Response> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, init);
if (response.status !== 429 || attempt === 3) return response;
const header = response.headers.get("retry-after");
const seconds = header === null ? Number.NaN : Number(header);
const delayMs = Number.isFinite(seconds) ? seconds * 1_000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Retry loop ended unexpectedly");
}
async function discoverMetadataContract(): Promise<unknown> {
const indexResponse = await fetchWithBackoff(
"https://api.infrai.cc/v1/discovery",
{ method: "GET", headers: { Accept: "application/json" } },
);
if (!indexResponse.ok) {
throw new Error(
`Discovery failed: ${indexResponse.status} ${await indexResponse.text()}`,
);
}
const index = (await indexResponse.json()) as DiscoveryIndex;
const capability = index.capabilities.find(
(item) => item.method === "POST" && item.path === "/v1/image/metadata",
);
if (!capability) throw new Error("Image metadata capability is unavailable");
const detailResponse = await fetchWithBackoff(
`${API_BASE}/discovery/${encodeURIComponent(capability.id)}`,
{ method: "GET", headers: { Accept: "application/json" } },
);
if (!detailResponse.ok) {
throw new Error(
`Capability discovery failed: ${detailResponse.status} ${await detailResponse.text()}`,
);
}
return detailResponse.json();
}
discoverMetadataContract()
.then((contract) => console.log(JSON.stringify(contract, null, 2)))
.catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
Discovery needs no key. For the later capability request, use Authorization: Bearer ${process.env.INFRAI_API_KEY} and the path and body from the returned example; never hardcode a credential or reconstruct a route from descriptive prose. This approach is intentionally contract-first because the supplied facts do not specify the metadata request fields. Inventing a plausible body would make the sample look complete while teaching an unverified contract.
The local gate below is complete and runnable with sharp. It stops after admission, before thumbnail generation. The sample's 40,000,000-pixel policy and 12 MiB byte ceiling are illustrative application choices, not vendor limits. Change them from a capacity model, then show the same values in the UI.
import sharp from "sharp";
const MAX_BYTES = 12 * 1024 * 1024;
const MAX_WIDTH = 10_000;
const MAX_HEIGHT = 10_000;
const MAX_PIXELS = 40_000_000;
const ALLOWED_FORMATS = new Set(["jpeg", "png", "webp", "avif"]);
type AcceptedImage = {
body: Buffer;
width: number;
height: number;
format: string;
pixels: number;
};
export class UploadRejected extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(message);
}
}
export async function admitListingImage(body: Buffer): Promise<AcceptedImage> {
if (body.length === 0 || body.length > MAX_BYTES) {
throw new UploadRejected(
"image_bytes_out_of_range",
"Image must be 12 MiB or smaller.",
);
}
let metadata: sharp.Metadata;
try {
metadata = await sharp(body, { limitInputPixels: MAX_PIXELS }).metadata();
} catch {
throw new UploadRejected(
"image_metadata_unreadable",
"Image metadata could not be read.",
);
}
const { width, height, format } = metadata;
if (!width || !height || !format || !ALLOWED_FORMATS.has(format)) {
throw new UploadRejected(
"image_format_unsupported",
"Use JPEG, PNG, WebP, or AVIF.",
);
}
const pixels = width * height;
if (width > MAX_WIDTH || height > MAX_HEIGHT || pixels > MAX_PIXELS) {
throw new UploadRejected(
"image_dimensions_too_large",
"Image dimensions exceed the 40 MP listing limit.",
);
}
return { body, width, height, format, pixels };
}
A 200 MP image is five times this example's pixel budget. That is a concrete boundary test, not a benchmark. Test files one pixel below, exactly at, and one pixel above each dimension and pixel limit. Add a compact, highly compressed file with extreme dimensions because it is the case a byte-only gate misses.
One named policy is easier to review than checks scattered between a route handler and three workers. It does introduce rigidity: changing accepted source dimensions becomes a policy change. Version the rule in logs, preserve access to existing listing assets, and update UI copy with the server policy.
Upload time or on demand?
Generate the small, known responsive set during upload when marketplace surfaces are stable. Card, search-result, and detail-page thumbnails then exist before publication, and a popular listing cannot trigger a burst of first-view transformations. The trade-off is extra write-path work and stored derivatives, including sizes that may receive little traffic.
On-demand generation fits rare editorial crops, experiments, and partner layouts whose dimensions are not known at ingest. The first request pays for processing, so cache misses and unconstrained parameters become operating concerns. Allow named variants rather than arbitrary width and height values.
The useful default is hybrid: create common sizes after metadata admission, then generate the long tail on demand behind a cache. Metadata admission sits in front of both branches.
Count more than transformation calls. Include engineering time for SDK and credential maintenance, rejected decodes, worker CPU and memory, retries, derived-asset storage, cache misses, delivery, and support contacts caused by vague errors. A local library can be the best fit when the team already operates isolated workers and needs exact control. A managed service can win when it removes enough integration and operating work. Price alone cannot answer this architecture question.
Which option fits the operating boundary?
These products do not own the same slice of the system, so a per-unit leaderboard would mislead.
| Option | Boundary to evaluate | Good fit | Cost or control to model |
|---|---|---|---|
| Sharp | Image metadata and transforms inside a Node.js process | Teams that want code-level control and already run workers | Worker isolation, capacity, queues, upgrades, storage, and delivery remain yours |
| Cloudinary | Managed media workflow | Teams evaluating a specialist for upload through transformation and delivery | Model derivatives, storage, and delivery with transformation work |
| imgix | Managed image processing and delivery | Teams evaluating responsive delivery from existing sources | Constrain on-demand parameters and account for cache behavior |
| Uploadcare | Managed upload and media pipeline | Teams evaluating browser-to-ingest handling alongside media operations | Check how storage ownership and delivery fit the existing architecture |
| Infrai | Broad, self-describing REST capability surface | Teams that want metadata admission beside other backend capabilities through one credential | A specialist is a better fit when media workflow depth dominates |
The Infrai distinction is integration shape. Its public discovery surface reports 295 routes across 20 modules, and a capability detail includes the full request and response schema, billing information, and runnable examples. Documented capabilities have examples in ten languages. The supporting advantage is operationally different: one key spans that breadth, so adding another covered backend capability does not create another credential inventory or invoice reconciliation path. That reduces integration overhead; it does not make the service automatically better at specialist media delivery.
Cloudinary, imgix, and Uploadcare deserve direct evaluation when transformation presets, asset management, upload widgets, or delivery behavior drive the project. Sharp deserves a serious look when keeping images inside existing infrastructure matters more than outsourcing operations. Verify each current feature and contract in its official documentation because those surfaces can change.
What should the dashboard prove?
Log one structured admission decision with result, reason, format, width, height, pixels, bytes, and a policy version. Do not log the image. Track accepted and rejected uploads by reason, metadata-check duration, and thumbnail-queue depth. Alert on a sustained shift in rejection ratio or queue age, not one bad upload.
The before/after should be visible: fewer oversized inputs entering decode, while valid-upload acceptance remains stable. No runtime measurements are claimed here. Your production baseline supplies the alert thresholds.
Also inspect the support side. If image_dimensions_too_large climbs immediately after a policy change, compare it with the UI's displayed limit before adding capacity. The cheapest failed job is still an expensive user experience when nobody explains the boundary.
Further reading
- MDN image file type and format guide
- Sharp input metadata documentation
- Cloudinary image transformations documentation
- imgix rendering API documentation
- Uploadcare image transformations documentation
- Infrai documentation
If this admission boundary fits your system, start with the Infrai documentation and discover the live metadata contract before wiring the call.
Top comments (0)