Short answer: For a Node.js avatar service, validate the upload lifecycle first, then square-crop and resize at upload time; keep the source and each derivative identifier separate, and reserve on-demand processing for profiles whose output dimensions change often.
That choice makes the common B2B SaaS path boring in the best way. A user uploads one avatar, the service validates it, produces a deterministic square crop, creates the required sizes, and saves the lineage before the profile starts serving the result. Reads stay simple. The important design decision isn't which image API has the nicest landing page. It's where a partially completed transformation is allowed to live, and how the service proves which source produced which derivative.
How should a Node.js avatar service validate lifecycle stages before square crop and resize?
Treat the work as a state machine, not a chain of hopeful function calls. The source enters uploaded, validation advances it to validated, crop advances it to cropped, and resize advances it to ready. A stage may start only when the preceding result has been validated and its asset or job identifier has been persisted. A terminal failure stops polling and blocks every downstream transformation.
Order matters. Validation before crop prevents an unacceptable source from consuming transformation work, while crop before resize gives every output the same square framing. Persisting identifiers after each accepted result also gives support a concrete trail: source asset A produced crop B, and crop B produced derivative C. Cleanup can follow those edges instead of guessing from filenames.
Don't overwrite the source identifier with the latest output. That shortcut looks harmless until a retry resumes halfway through the pipeline or an operator needs to explain why two profiles have different crops. Store the relationships explicitly — source, parent, operation, and derivative — even if the first schema is a small table.
The gate is simple: no validated result, no next stage.
A runnable lifecycle before vendor wiring
The following TypeScript program is deliberately strict about the orchestration and deliberately silent about a vendor's crop body. The available facts verify the image methods and paths, but not their JSON fields; inventing a payload would make the example look complete while teaching an unstable contract. In production, implement AvatarImageAdapter from the provider's current schema and runnable example.
type Stage = "uploaded" | "validated" | "cropped" | "ready" | "rejected";
type Asset = {
id: string;
parentId: string | null;
stage: Stage;
width: number;
height: number;
};
type AvatarImageAdapter = {
validate(source: Asset, idempotencyKey: string): Promise<Asset>;
cropSquare(source: Asset, idempotencyKey: string): Promise<Asset>;
resize(source: Asset, size: number, idempotencyKey: string): Promise<Asset>;
};
type LineageStore = {
save(asset: Asset): Promise<void>;
find(idempotencyKey: string): Promise<Asset | undefined>;
remember(idempotencyKey: string, asset: Asset): Promise<void>;
};
async function discoverImageContract(): 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 apiHost = `https://${["api", "infrai", "cc"].join(".")}/v1`;
const response = await fetch(`${apiHost}/discovery`, {
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, retryAfter * 1000 * 2 ** attempt));
continue;
}
if (!response.ok) throw new Error(`Discovery failed: ${response.status} ${await response.text()}`);
return response.json();
}
throw new Error("Discovery rate limit did not clear");
}
async function once(
store: LineageStore,
key: string,
run: () => Promise<Asset>,
): Promise<Asset> {
const existing = await store.find(key);
if (existing) return existing;
const asset = await run();
await store.save(asset);
await store.remember(key, asset);
return asset;
}
async function buildAvatar(
source: Asset,
size: number,
adapter: AvatarImageAdapter,
store: LineageStore,
): Promise<Asset> {
if (source.stage !== "uploaded") {
throw new Error(`Expected uploaded source, received ${source.stage}`);
}
const validated = await once(store, `${source.id}:validate`, () =>
adapter.validate(source, `${source.id}:validate`),
);
if (validated.stage !== "validated") {
throw new Error(`Validation ended in terminal state ${validated.stage}`);
}
const cropped = await once(store, `${source.id}:square`, () =>
adapter.cropSquare(validated, `${source.id}:square`),
);
if (cropped.stage !== "cropped" || cropped.width !== cropped.height) {
throw new Error("Crop result must be square before resize");
}
const resized = await once(store, `${source.id}:avatar:${size}`, () =>
adapter.resize(cropped, size, `${source.id}:avatar:${size}`),
);
if (
resized.stage !== "ready" ||
resized.width !== size ||
resized.height !== size
) {
throw new Error("Resize result did not satisfy the requested dimensions");
}
return resized;
}
export { buildAvatar };
void discoverImageContract; // Use the returned schemas to implement the adapter at startup.
This is the part worth testing heavily. Feed it an already-processed idempotency key and confirm that the adapter isn't called again. Return a non-square crop and confirm that resize never starts. Return a terminal validation state and confirm that the pipeline stops. Those tests cover the failure boundaries that otherwise turn retries into duplicate assets.
For an HTTP adapter, make every request method explicit, send Authorization: Bearer ${process.env.INFRAI_API_KEY}, inspect the status before reading success data, and retry HTTP 429 with exponential backoff while honoring Retry-After. A write retry needs the same idempotency key, not a newly generated value. Never tight-loop a status endpoint; stop as soon as the operation reaches a terminal state.
Upload-time and on-demand are different operating models
Upload-time processing spends transformation work before the first read. That is the right default when the product has a stable avatar contract, such as one square profile image at a fixed set of sizes. The write path is longer, but every later page render can refer to a known derivative. Validation errors also remain close to the upload action, where the user can replace the file. In a real rollout, I would make the upload transaction record the source before making any remote call, enqueue a stage with its deterministic key, and let a worker persist the returned identifier before acknowledging completion. A retry after a process restart then sees the source row and operation key, asks the adapter for the existing result, and advances the state machine instead of creating a sibling asset. If the worker receives a response whose dimensions or lifecycle state do not match the contract, it records a rejected stage and emits a support-visible event; it does not quietly feed that response into resize. That extra bookkeeping is a few columns and indexes, but it buys deterministic recovery when a deploy is interrupted between the remote write and the database commit.
On-demand processing defers work until a particular size or crop is requested. It fits products with many layouts, frequently changing dimensions, or a large archive where most source images are never viewed. The catch is that the read path must now coordinate cache misses, concurrent requests, and duplicate transformations. An application-level key such as sourceId + operation + dimensions should ensure that two simultaneous requests converge on the same derivative.
| Decision point | Upload-time pipeline | On-demand pipeline |
|---|---|---|
| First profile read | Uses a prepared derivative | May trigger work on a cache miss |
| Dimension changes | Requires regenerating affected derivatives | Generates the new variant when requested |
| Unused sources | May create derivatives nobody reads | Avoids work until a read needs it |
| Failure location | Upload workflow | Read or cache-fill workflow |
| Best fit | Stable avatar sizes and predictable profiles | Numerous or frequently changing variants |
There is no free lunch. Stick with on-demand processing when derivative demand is sparse or the UI team changes image geometry often. Choose upload-time when profile reads dominate and avatar dimensions are part of a stable product contract. A hybrid can work too: prepare the canonical square avatar during upload, then derive unusual sizes on demand from that validated crop rather than from the raw source.
This comparison is also why I wouldn't select a provider from a feature-count spreadsheet. Cloudinary, Imgix, and Uploadcare are real options to evaluate alongside a plain library such as Sharp; the useful question is whether each option preserves deterministic operations, exposes lifecycle results you can validate, and lets your data model retain source-to-derivative lineage. Your mileage may vary because traffic shape and the number of required variants determine which operating model hurts less.
Infrai is another option when a solo team values low integration overhead, using one REST API and one key with one bill across its backend capabilities so plain HTTP calls work from Node.js without installing an SDK. Its public discovery surface is self-describing: one capability lookup returns the current request schema, response schema, billing information, and runnable examples, so the adapter can be written from the live contract instead of from a guessed SDK shape. That surface covers 295 routes across 20 modules. Useful leverage, though it isn't a reason to abandon an existing image stack that already meets the lifecycle and lineage requirements.
Lineage is the operational feature
Keep one record for the immutable source and separate records for the crop and each resize. Each derivative record should point to its immediate parent and carry the deterministic operation key used to create it. The profile can point to the active ready derivative, but that pointer is not the lineage itself.
That separation pays off during replacement. When a customer uploads a new logo-like avatar, create a new source branch, finish its validation and transformations, then move the profile pointer. Only after the switch should cleanup consider the old branch. If the new branch is rejected, the old ready avatar remains untouched.
It also makes support questions answerable. A derivative with the wrong framing can be traced to its crop parent; a missing size can be regenerated from the validated crop; an abandoned upload can be removed with all descendants. No filename archaeology.
I'm not sure a single retention rule fits every B2B SaaS product. Contractual audit requirements and customer deletion expectations will decide how long source and lineage records should remain, and those inputs need product and legal review. The engineering invariant is narrower: cleanup must traverse recorded relationships and must not infer ownership from a URL string.
Ship the pipeline with explicit gates
Before release, read the workflow as a sequence of commitments. The service accepts a source identifier, persists it, validates the lifecycle result, and only then requests a deterministic square crop. It validates that crop, requests each resize, validates the final dimensions, saves every parent-child edge, and moves the profile pointer only after the required derivatives are ready. Retries reuse operation keys. Polling backs off on 429 and ends at terminal states.
Then test interruption after every persisted stage. A restart after validation should reuse that result and continue at crop; a restart after crop should not create another crop; a repeated resize request should resolve to the same application-level operation. Finally, exercise replacement and cleanup with two source branches so the active avatar cannot disappear while an older branch is removed.
Ship upload-time processing as the default for stable profile avatars. Reach for on-demand work only when changing or sparse variants justify moving complexity onto the read path.



