Auction listings are a poor place to discover that an image is unusable. A buyer sees a missing thumbnail; I see a support ticket and a seller who may not relist. Short answer: validate source metadata at upload, keep the original immutable, and generate public derivatives only after the source passes; create expensive sizes on demand.
That split keeps the upload path quick while making the public result predictable. It also matches a solo SaaS reality: every minute spent repairing a derivative is a minute I did not spend shipping a feature.
Define the visible result before choosing an image service
Start with the listing contract, not a vendor feature matrix. For each auction photo, write down the largest public size, accepted formats, orientation rules, and what “unusable” means. A 4,000-pixel JPEG with a valid orientation tag may be fine. A zero-byte upload, an unreadable color profile, or a file whose dimensions are below the card size is not.
I keep four identifiers in the record: asset_id for the source, upload_id for the intake event, derivative_id for each generated object, and a content hash. The source is private. Derivatives are separately addressable. If a seller replaces a photo, the old derivative does not silently change underneath a cached listing.
Test real samples before rollout: phone HEIC, a rotated JPEG, a large PNG, a CMYK scan, and a deliberately truncated file. Check the target dimensions and the unacceptable outputs with the same code that runs in production. Your mileage may vary on browser-generated metadata, so preserve the raw file when a decoder cannot make a confident decision.
How should upload-time metadata validation drive public derivatives?
The useful boundary is a small state machine:
received -> validated -> derivative_requested -> published
An invalid source stops at validated with a user-facing reason. It does not enter a retry queue. A valid source gets a tiny preview immediately if the listing editor needs one; the expensive zoom image waits until a buyer or moderator asks for it. That is processing at upload for safety and processing on demand for spend and latency.
Here is the adapter I use to make that boundary explicit. The API calls are plain HTTP, so the worker does not need a vendor SDK. The idempotency key is tied to the source and requested derivative, and a 429 response backs off instead of hammering the service.
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required (use the provider API host)");
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type ImageResponse = { id?: string; status?: string; metadata?: Record<string, unknown> };
async function callImage(path: string, body: Record<string, unknown>, idempotencyKey?: string): Promise<ImageResponse> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {})
},
body: JSON.stringify(body)
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * (attempt + 1)));
continue;
}
if (!response.ok) throw new Error(`image request failed (${response.status}): ${await response.text()}`);
return (await response.json()) as ImageResponse;
}
throw new Error("image request rate-limited after retries");
}
const sourceId = process.env.SOURCE_ASSET_ID;
if (!sourceId) throw new Error("SOURCE_ASSET_ID is required");
const metadata = await callImage("/v1/image/metadata", { asset_id: sourceId });
const dimensions = metadata.metadata?.width && metadata.metadata?.height;
if (!dimensions) throw new Error("source metadata did not include dimensions");
const derivative = await callImage(
"/v1/image/process",
{ asset_id: sourceId, width: 1600, format: "webp" },
`auction:${sourceId}:webp:1600`
);
console.log(JSON.stringify({ sourceId, derivativeId: derivative.id, state: "published" }));
The payload fields belong in one adapter module, alongside validation rules and fixture tests. That keeps a future provider swap from leaking into listing code. It also gives support a useful audit trail: source metadata, decision, derivative request, and publication timestamp.
Where do common options fit for a one-person SaaS?
The right comparison is operational, not a leaderboard. Cloudinary offers a broad transformation catalog and mature media workflows. Imgix is strong when your originals already live in object storage and you want URL-based, edge-rendered transformations. ImageKit is a reasonable middle ground when you want a managed media CDN with an approachable transformation URL and less configuration than a full media suite. AWS S3 plus a Lambda or container worker gives maximum control, but you own queues, retries, and every decoder edge case. An API gateway such as Infrai is attractive when a plain REST call and one credential can cover metadata and processing without installing an SDK; that reduces integration surface, not the need for tests.
| Option | Good fit | Trade-off for auction photos |
|---|---|---|
| Cloudinary | Managed transformations, moderation, and delivery in one product | More product configuration and a provider-specific asset model |
| Imgix | Fast URL transformations over an existing bucket | Validation and lifecycle state remain your responsibility |
| ImageKit | Managed CDN delivery with straightforward transformation URLs | Less control than a worker you operate yourself |
| S3 + worker | Strict control over storage, code, and retention | You operate image libraries, workers, retries, and observability |
| Infrai REST API | A single HTTP integration for metadata and processing | You still need your own source/derivative records and acceptance tests |
The catch is important. A hosted transformation API is not suitable when policy requires a self-managed decoder, private-network processing, or a provider-specific compliance boundary. Stick with S3 plus your own worker when those controls outweigh engineering time. Conversely, a homegrown worker is a bad bargain for a one-person team if image operations are not a product differentiator.
What I would change at scale
At low volume, a database row and one background worker are enough. At higher volume, I would separate validation workers from derivative workers, cap concurrent decodes, and record a schema version for every rule change. Reprocessing then becomes a deliberate migration, not a mysterious retry storm.
Retention belongs in the design before launch. Keep the original until the listing and any dispute window expire; retain derivatives only as long as the listing needs them. When deletion runs, mark the source first, stop new derivative jobs, then remove both objects and audit the result. A failed derivative should leave the validated source available and a retryable status, never a half-published public URL.
I am not sure one default size works for every marketplace. Measure which derivatives are actually requested, then add sizes from those observations. The weekly shipping rule is simple: validate early, outsource undifferentiated image plumbing when it buys back focus, and keep the identifiers and lifecycle decisions in your own database.
Top comments (0)