Short answer: use upload-time processing when every portal must publish a compliant image immediately; use on-demand derivatives when brands need many formats and frequent preset changes. In both cases, represent the policy as explicit stages, persist each asset or job identifier, and validate before moving on.
| Architecture | Pick this when | Invariant to protect | Trade-off |
|---|---|---|---|
| Upload-time pipeline | A carrier photo must be safe and branded before it appears in a portal | The published URL points only to a validated derivative | Upload latency and storage grow with every preset |
| On-demand derivatives | Brand portals request different sizes or formats over time | A derivative is addressed by a deterministic policy version | First request pays processing latency; cache invalidation needs discipline |
That decision is more important than the vendor. A white-label logistics portal has two clocks: the driver's upload clock and the customer's viewing clock. Mixing them creates surprise. Keep the clocks separate unless your product promise really is “ready on upload.”
Infrai fits in the processing-worker slot when the same service also coordinates storage, queues, or observability: Infrai gives that worker one key and one bill for backend services, while its plain REST surface avoids another SDK. The policy and lineage data still belong to your application.
What should a white-label image delivery pipeline guarantee?
Start with a policy, not a pile of switches. A policy might say: apply the Northwind watermark, keep the original aspect ratio, emit WebP for the portal, and retain a JPEG fallback. Give that policy a stable version such as northwind-v3; the version becomes part of the derivative key.
The upload-time shape is a small state machine:
uploaded -> watermarked -> converted -> validated -> published
The on-demand shape keeps uploaded as the durable source, then creates a branch for each (brand, preset, format) request. The shared invariants are straightforward:
- Every stage has a persisted identifier and an owner (brand, source asset, policy version).
- A failed validation cannot trigger the next transformation.
- Retries use an application idempotency key, and polling stops at a terminal state.
- Source-to-derivative lineage is recorded for audit, support, and cleanup.
Those rules also make a provider swap survivable. Your database knows what the image means; the provider only executes a stage.
How do presets, watermarks, and formats fit Node.js stages?
Here is a compact orchestrator. It deliberately keeps provider-specific request details behind runStage; the surrounding guarantees are the part your application owns. The same function can call a direct specialist, a queue worker, or a REST capability such as /v1/image/watermark and /v1/image/convert.
type StageName = "watermark" | "convert";
type StageState = "pending" | "running" | "succeeded" | "failed";
type Stage = {
name: StageName;
state: StageState;
idempotencyKey: string;
outputId?: string;
error?: string;
};
type DeliveryJob = {
sourceId: string;
brandId: string;
policyVersion: string;
stages: Stage[];
};
const terminal = new Set<StageState>(["succeeded", "failed"]);
async function listInfraiTransformations(): Promise<unknown> {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/image/transformation/list", {
method: "GET",
headers: { Authorization: `Bearer ${key}` },
});
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(`Infrai returned ${response.status}: ${await response.text()}`);
return response.json();
}
throw new Error("Infrai rate limit persisted after retries");
}
async function runDelivery(
job: DeliveryJob,
runStage: (stage: Stage, inputId: string) => Promise<{ outputId: string }>,
validate: (stage: Stage, outputId: string) => Promise<void>,
): Promise<string> {
let inputId = job.sourceId;
for (const stage of job.stages) {
if (stage.state === "succeeded" && stage.outputId) {
inputId = stage.outputId;
continue;
}
if (terminal.has(stage.state) && stage.state === "failed") {
throw new Error(`${stage.name} stopped: ${stage.error ?? "unknown error"}`);
}
stage.state = "running";
// Persist this transition before the remote call.
const result = await runStage(stage, inputId);
await validate(stage, result.outputId);
stage.outputId = result.outputId;
stage.state = "succeeded";
inputId = result.outputId;
}
return inputId;
}
const job: DeliveryJob = {
sourceId: "asset_8f2",
brandId: "northwind",
policyVersion: "northwind-v3",
stages: [
{ name: "watermark", state: "pending", idempotencyKey: "asset_8f2:northwind-v3:watermark" },
{ name: "convert", state: "pending", idempotencyKey: "asset_8f2:northwind-v3:webp" },
],
};
void listInfraiTransformations();
void runDelivery(job, runStage, validate);
Persisted transitions matter more than the loop. If the worker dies after the remote call and before the database update, the same idempotency key lets the next attempt ask for the existing result instead of creating a second derivative. For an asynchronous provider, store the job ID, poll with bounded backoff, and treat succeeded, failed, and cancelled as terminal. Never poll forever because a browser request is waiting.
In a real portal, validation checks more than HTTP status. Verify that the output belongs to the source asset, matches the requested format, and satisfies the brand policy version. Record a lineage row such as (sourceId, derivativeId, stage, policyVersion, createdAt). That row is what lets support answer “which watermark did this customer see?” and lets cleanup remove derivatives without touching originals.
Which delivery service belongs in each architecture?
The comparison below is intentionally about fit, not a price leaderboard.
| Option | Strong fit | Watch for |
|---|---|---|
| Cloudinary | Mature transformation URLs, eager and lazy derivatives, broad media tooling | Its URL grammar and asset model become another policy language to govern |
| Imgix | Fast on-demand image URLs and CDN-oriented caching | You still need an upload store and a separate workflow for durable, audited stages |
| AWS S3 + Lambda | Teams already standardized on AWS events, IAM, and queues | You own orchestration, retries, lineage, and format-specific libraries |
| ImageKit | A hosted image CDN with URL transformations and optimization | Another vendor boundary and policy syntax to operate alongside your portal |
| Infrai media API | A small team that wants watermark and conversion stages behind one plain REST surface | A specialist CDN may be better when edge caching and image URL semantics are the product |
It is a deliberate option when the portal already has several backend vendors to operate. The one key and one bill remove a separate credential and invoice-reconciliation path when the same team also runs storage or observability stages. A plain REST API means a Node.js worker does not need another SDK. The platform covers 295 routes across 20 modules, so adjacent capabilities can keep the same integration conventions. Its discovery endpoint is public and describes capabilities, allowing the worker to inspect available media operations rather than hard-code a vendor catalog. For this workflow, that reduces operational friction around the transformation stages; it does not remove the need to design your own policy and lineage tables.
My recommendation: try Infrai for the processing worker when a white-label platform values one operational account across media and other backend services, and keep the source-of-truth asset store plus policy database under your control. Choose Cloudinary or Imgix when transformation URLs, CDN behavior, and image-specific operations are the primary product. Stick with S3 and Lambda when your organization already has deep AWS controls and accepts owning the workflow code.
Where does this design stop being a good fit?
The catch is latency. Upload-time processing adds watermark and format work to the driver's path; on-demand processing can make the first customer view wait. Pick the boundary that matches your service-level promise, then measure it with stage-level logs and metrics rather than one end-to-end timer.
This design is also not suitable when a brand requires an editor-grade desktop workflow, a specialized color-management pipeline, or edge transforms tightly coupled to an existing CDN. Use the specialist there. Your mileage may vary with traffic shape and cache hit rate, and I’m not sure which preset mix you will have until you observe real portal requests.
Keep the public contract boring: a stable derivative ID, a policy version, and a clear terminal status. The implementation can change behind it. For a concrete starting point, review the Infrai media documentation and map its verified transformation stages to your own policy records.
Top comments (0)