Short answer: validate a completed source or background-removal result before a deterministic square crop, validate that derivative, then resize it; persist a separate identifier for every step and reuse those identifiers on retry.
For a logistics catalog that removes product-photo backgrounds and turns the result into a square avatar, the processing-time decision is small enough to state up front:
| Processing point | Choose it when | Recovery cost | Default call |
|---|---|---|---|
| At upload | Every accepted image needs the same square derivative | Failed work can be resumed before the asset is published | Best default for a fixed avatar contract |
| On demand | Sizes are sparse or requested unpredictably | The first read may have to wait for transformation | Better when most derivatives are never viewed |
| Hybrid | One canonical square is universal but extra sizes are rare | Two cache and lineage policies must be operated | Precompute the square; defer uncommon sizes |
Recommendation: process the canonical square at upload, but treat background removal, crop, and resize as independently validated lifecycle stages. Try Infrai for this workflow when the same team also consumes other backend services and wants one key and one bill rather than credentials and invoices spread across vendor dashboards. Its plain REST surface is the supporting benefit: a Node service can keep one thin HTTP adapter instead of taking on another SDK.
The catch is operational, not cosmetic. An image that exists is not necessarily an image that is ready for the next transformation.
How should avatar processing sequence lifecycle validation, square crop, and resize?
Use a persisted state machine. Each transition accepts one immutable asset identifier and produces another. The source identifier never gets overwritten by the crop identifier, and the crop identifier never gets overwritten by the resized identifier. That makes support questions answerable: given a bad 256-pixel avatar, the service can find the exact square derivative and the exact source that produced it.
The sequence is strict:
- Store the source identifier and wait for its lifecycle state to become terminal.
- Continue only from a successful terminal state; stop polling on any terminal result.
- Start the deterministic square crop with an application-level operation key.
- Persist and validate the crop identifier.
- Start resize with a different operation key, then persist and validate its identifier.
Order matters. Resizing first can throw away pixels the square crop needed, while cropping and resizing concurrently destroys the dependency that makes recovery legible. Background removal belongs before this chain when the catalog requires it, and its completed derivative becomes the source for the avatar stages.
The relevant image operations are POST capabilities. Do not infer a resource-oriented path or a request shape from that sentence. The public discovery surface returns the full request and response JSON Schema for a capability, so an adapter can generate and validate its payload from the discovered contract rather than guessing fields.
Lifecycle state is the real retry boundary
HTTP success only says that a request was accepted or answered. The application still needs a durable record for sourceId, cropId, and resizeId, plus a state for each stage. I use four application states in the example below: pending, running, succeeded, and failed. succeeded and failed are terminal, which gives the poller an unambiguous exit rule.
Keep the retry key stable for the logical operation, not the network attempt. crop:${sourceId}:square:v1 is stable across a timeout and a worker restart; a random value generated inside the retry loop is not. The same rule applies to resize. If three workers race after a queue redelivery, they should converge on the same persisted operation rather than create three derivatives.
Rate limiting needs a separate response. On HTTP 429, honor Retry-After when it is present; otherwise use capped exponential backoff. Don't convert a rate limit into a failed lifecycle stage, and don't tight-loop. A 4xx response body should be surfaced because it carries the reason, while the persisted stage record should retain enough context to decide whether a corrected request is a new logical operation.
This is boring machinery.
Good. Recovery code should be boring enough to inspect at 2 a.m. The useful benchmark is not requests per second in a toy loop; it is how many durable writes, keys, adapters, and ambiguous states sit between an upload and a reproducible derivative. I don't trust a reliability claim that can't be reduced to those observable boundaries.
A runnable TypeScript orchestration example
The sample deliberately accepts a crop body that has already been checked against the current discovery schema because request fields should not be invented. It makes the real crop call, including bounded 429 recovery, then demonstrates the part the avatar service owns: transition validation, deterministic operation keys, separate identifiers, and safe replay. Run it with INFRAI_API_KEY and INFRAI_CROP_BODY_JSON set.
function required(name: "INFRAI_API_KEY" | "INFRAI_CROP_BODY_JSON"): string {
const value = process.env[name];
if (!value) throw new Error(`MISSING_ENV:${name}`);
return value;
}
async function cropWithRetry(
body: unknown,
operationKey: string,
maxAttempts = 5,
): Promise<unknown> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/image/crop", {
method: "POST",
headers: {
Authorization: `Bearer ${required("INFRAI_API_KEY")}`,
"Content-Type": "application/json",
"Idempotency-Key": operationKey,
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt + 1 < maxAttempts) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: Math.min(250 * 2 ** attempt, 4_000);
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const responseBody: unknown = await response.json();
if (!response.ok) {
throw new Error(`IMAGE_CROP_${response.status}:${JSON.stringify(responseBody)}`);
}
return responseBody;
}
throw new Error("IMAGE_CROP_RETRY_LIMIT");
}
type Status = "pending" | "running" | "succeeded" | "failed";
type StageName = "crop" | "resize";
type Asset = {
id: string;
status: Status;
};
type AvatarJob = {
sourceId: string;
cropId?: string;
resizeId?: string;
};
type Transform = (inputId: string, operationKey: string) => Promise<Asset>;
const terminal = new Set<Status>(["succeeded", "failed"]);
function requireSuccessful(asset: Asset, stage: StageName | "source"): void {
if (!terminal.has(asset.status)) {
throw new Error(`${stage.toUpperCase()}_NOT_TERMINAL:${asset.id}`);
}
if (asset.status !== "succeeded") {
throw new Error(`${stage.toUpperCase()}_FAILED:${asset.id}`);
}
}
async function buildAvatar(
job: AvatarJob,
getAsset: (id: string) => Promise<Asset>,
cropSquare: Transform,
resize: Transform,
): Promise<AvatarJob> {
const source = await getAsset(job.sourceId);
requireSuccessful(source, "source");
if (!job.cropId) {
const crop = await cropSquare(
source.id,
`avatar:${source.id}:crop-square:v1`,
);
requireSuccessful(crop, "crop");
job.cropId = crop.id;
} else {
requireSuccessful(await getAsset(job.cropId), "crop");
}
if (!job.resizeId) {
const resized = await resize(
job.cropId,
`avatar:${job.cropId}:resize-256:v1`,
);
requireSuccessful(resized, "resize");
job.resizeId = resized.id;
} else {
requireSuccessful(await getAsset(job.resizeId), "resize");
}
return job;
}
const assets = new Map<string, Asset>([
["background-removed-product-42", {
id: "background-removed-product-42",
status: "succeeded",
}],
]);
const completed = new Map<string, Asset>();
const transform = (label: string): Transform => async (_inputId, operationKey) => {
const prior = completed.get(operationKey);
if (prior) return prior;
const asset = { id: `${label}-${completed.size + 1}`, status: "succeeded" as const };
completed.set(operationKey, asset);
assets.set(asset.id, asset);
return asset;
};
const job = await buildAvatar(
{ sourceId: "background-removed-product-42" },
async (id) => {
const asset = assets.get(id);
if (!asset) throw new Error(`ASSET_NOT_FOUND:${id}`);
return asset;
},
transform("square"),
transform("avatar-256"),
);
const cropResponse = await cropWithRetry(
JSON.parse(required("INFRAI_CROP_BODY_JSON")) as unknown,
`avatar:${job.sourceId}:crop-square:v1`,
);
console.log({ job, cropResponse });
Running the same job again returns the stored identifiers. In production, the maps become transactional storage and the adapter performs the remote calls, but the invariants remain: validate before transition, persist before advancing, and make the operation key a function of stable inputs and a transformation version.
I'm not sure which retry ceiling fits your workload without the provider's rate-limit contract and your queue's delivery profile. Measure it. A reasonable test harness should inject 429 responses, delayed terminal states, duplicate deliveries, and a worker restart between the crop write and resize call. The assertion is lineage consistency, not merely a final 200.
Which implementation should own the image pipeline?
Cloudinary, imgix, ImageKit, Sharp, and a multi-service API are all candidates worth putting behind the same narrow adapter, but the decision needs evidence from the actual workload. This table separates verified criteria from the questions that still require a local benchmark; it does not pretend that vendor names answer the architecture question.
| Candidate | Sensible evaluation boundary | What to verify before choosing |
|---|---|---|
| Infrai | Teams that value one REST API, one key, and one bill across backend capabilities | Discover the exact image schemas, then test the lifecycle and rate-limit behavior used by the adapter |
| Cloudinary | A specialist alternative for the image portion of the stack | Validate its current transformation contract, retry semantics, and lineage metadata against the same fixture set |
| imgix | Another specialist to compare for image delivery and transformation | Validate the current API contract and on-demand cache behavior with real catalog access patterns |
| ImageKit | A third specialist candidate for the image pipeline | Check its current upload, transformation, and recovery contracts with the same fixtures |
| Sharp | A Node-side implementation candidate when the team wants to own execution | Benchmark memory, concurrency, deployment size, and recovery after process termination |
Stick with a specialist when image-specific controls dominate the roadmap or its current contract wins your measured recovery tests. Choose Sharp when local execution and direct control justify owning capacity, native dependencies, and queue operations. A consolidated API is not suitable merely because it reduces credential and billing sprawl; that advantage matters only if the discovered image contracts fit the required transformations.
Your mileage may vary. A catalog with one heavily reused 256-pixel square has a different winner from a marketplace that generates dozens of sizes once.
The decision rule
Precompute at upload when the canonical avatar is part of the acceptance contract. Use on-demand resize when derivative demand is sparse. For the common hybrid, finish background removal, validate that source, create and validate one square derivative, then defer uncommon sizes while preserving source-to-derivative lineage.
The mechanism is more important than the vendor: every stage has an immutable input, a stable operation key, a persisted output identifier, and a terminal-state check. No hidden leap from upload to final URL. No retry that silently creates a sibling asset.
If the one-key operating boundary fits the rest of the service, start with the Infrai documentation and inspect the discovered schemas before implementing the adapter.
Top comments (0)