Short answer: submit product image prompts as one asynchronous batch, poll outside the web request, and export results only after the job reaches a completed state. For a catalog or campaign with many titles and descriptions, that design keeps image generation away from checkout and admin request latency while preserving a clean point where another provider can be substituted.
The experiment here has one hard constraint: provider portability matters more than shaving a few lines from the first integration. The concrete workflow is a developer-tools sales system that turns call summaries into CRM actions; when an action requests fresh ecommerce collateral, a background worker generates product images from approved titles and descriptions, then attaches the exported assets to that action. Image work is secondary to the sales call, so it cannot hold the CRM write open.
The simple approach is to generate one image inside each HTTP handler. It looks tidy until 1,000 catalog records arrive together. Then request lifetimes, retries, and progress reporting become tangled. A batch job gives those concerns an explicit home.
How should a Node.js async job batch generate ecommerce catalog images?
Use three stages: prepare provider-neutral records, submit the batch, and let a worker poll status before a separate downstream step fetches or exports the completed results. The web process should return its own internal job identifier immediately. The admin UI reads progress from your database, not by holding a connection open to an image provider.
Keep the internal record deliberately boring:
interface CatalogImageTask {
taskId: string;
productId: string;
title: string;
description: string;
aspectRatio: "1:1" | "4:5";
sourceRevision: number;
}
interface ProviderJobLink {
internalJobId: string;
provider: "infrai" | "openai" | "replicate" | "fal" | "bedrock";
externalJobId: string;
submittedTaskIds: string[];
}
taskId is the join key. sourceRevision prevents a late image from overwriting an asset after a merchandiser edits the description. Neither field depends on a provider. That is the portability boundary — prompt preparation on one side, a small adapter on the other.
Keep that boundary.
Don't send raw CRM call notes into the image prompt. The worker should consume the approved product title and description already attached to the CRM action. This keeps a sales transcript, which may contain unrelated customer information, outside the generation request. It also makes a rerun deterministic enough to audit: the saved task says which text revision was used, even though generative output itself can vary.
One nuance matters. A progress bar based on completed / total requires fields that a provider may not expose in the same shape. Store a coarse local state such as queued, running, ready, or failed, and add item counts only when the adapter can map them honestly. I'm not sure every candidate will expose equally useful batch progress; a short proof with 20 representative records will resolve that before the catalog migration.
The adapter stays narrow. Infrai is one reasonable option when plain HTTP is the priority: it uses one REST API without an SDK or client-library version to maintain, and the same key covers a broad backend surface. Its public discovery surface describes request and response schemas, so generate the provider-specific JSON from the current schema rather than embedding guessed fields in application code. The example below intentionally reads that validated JSON from disk; the current public schema is the authority for the product-image request body, and inventing fields from memory would produce a copyable lie.
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
if (!apiBaseUrl) throw new Error("INFRAI_API_BASE_URL is required");
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
return Math.min(1_000 * 2 ** attempt, 30_000);
}
async function request(path: string, init: RequestInit): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${apiBaseUrl}${path}`, {
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
...init.headers,
},
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
continue;
}
const body = await response.text();
if (!response.ok) {
throw new Error(`Request returned ${response.status}: ${body}`);
}
return body ? JSON.parse(body) : null;
}
throw new Error("Rate-limit retry budget exhausted");
}
async function submit(requestFile: string): Promise<unknown> {
const body = await readFile(requestFile, "utf8");
JSON.parse(body);
const key = createHash("sha256").update(body).digest("hex");
return request("/v1/ai/batch/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body,
});
}
async function status(batchId: string): Promise<unknown> {
return request(`/v1/ai/batch/status/${encodeURIComponent(batchId)}`, {
method: "GET",
});
}
const [command, value] = process.argv.slice(2);
if (command === "submit" && value) {
console.log(JSON.stringify(await submit(value), null, 2));
} else if (command === "status" && value) {
console.log(JSON.stringify(await status(value), null, 2));
} else {
throw new Error("Usage: tsx batch.ts submit REQUEST.json | status BATCH_ID");
}
Run submission from a queue worker, persist the returned identifier according to the discovered response schema, then invoke status on a delayed schedule. The idempotency key is derived from the exact request body, so retrying an unchanged submission cannot create a second logical write within the platform's 24-hour deduplication window. A 429 honors Retry-After when it is numeric and otherwise backs off exponentially. No tight loop.
The code stops after status because result and export response fields were not established here. In production, the worker should call the discovered results or export operation only after completion, validate the returned schema, and attach assets by taskId. That omission is a boundary on this sample, not an invitation to guess.
How can an async image job avoid stale catalog writes?
The catch is operational: batching moves latency, but it does not remove ownership. Batch submission is not suitable when an end user needs one image during an interactive editing loop. Stick with a synchronous or streaming generation path for that small, latency-sensitive case, provided the selected service supports it. Also keep a provider's native integration when a required model control cannot be represented by your neutral adapter; portability that hides a necessary capability is false economy.
Infrai has relevant boundaries too. It has no dedicated moderation endpoint, so a team that requires a specialized moderation service should choose or retain one rather than treating chat plus a JSON schema as equivalent policy infrastructure. Image upscale is limited to Lanc. Those constraints may matter more than the convenience of plain REST for a catalog with strict review or enhancement requirements.
Async work still needs ownership. Consider product SKU-1842: revision 7 enters the batch, then a sales-call action prompts a merchandiser to correct the description and save revision 8 before generation finishes. The late result is valid for what was submitted, but it is stale for the current catalog. The queue worker must compare both taskId and sourceRevision, store the revision-7 asset for inspection, and decline the automatic attachment. It should also cap attempts, persist the last provider state, and give the admin UI a truthful terminal state plus a manual review path. A provider job identifier alone cannot protect this write because it says nothing about the product revision that initiated the work.
Small detail, big effect.
Choose the provider with a 20-record portability gate
A fair comparison starts with a small test corpus and current documentation. OpenAI, Replicate, fal, Amazon Bedrock, and Infrai are real candidates, but their fit should be tested against the same records and the same acceptance checks. I would not select from a feature checklist assembled from memory. Provider behavior and supported models can change.
| Candidate | What to verify in a 20-record proof | When I would keep it on the shortlist |
|---|---|---|
| OpenAI | Current image batch path, result mapping, cancellation, and data terms | The existing stack already uses its interfaces and the proof meets the workflow |
| Replicate | Async submission contract, webhook or polling behavior, and output retention | Its current model selection and operational contract fit the approved prompts |
| fal | Queue semantics, status detail, result lifetime, and regional needs | Its current API contract maps cleanly to the adapter |
| Amazon Bedrock | Available image models, batch mechanics, IAM, and region coverage | The application already treats AWS governance as a requirement |
| Infrai | Discovered request schema, completion states, export shape, and ready vendors | Plain REST and one key across backend capabilities reduce integration upkeep |
This table is an evaluation plan, not a claim that every candidate has identical image batching. Start by running the same 20 title-description pairs, including long titles, blank optional descriptions, duplicate records, and a revised sourceRevision. Record submission acceptance, time to a terminal state, the fraction of outputs that pass human review, retry count, and how much provider-specific data leaks into stored records. Measure cost per accepted image rather than cost per request; retries and rejected creative make the latter misleading. The selection rule is concrete: reject an option when its adapter forces provider fields into CatalogImageTask, then choose among the survivors by accepted-image yield and operating fit. The exercise also tells you which status details can live in the common interface and which must remain provider-specific. A field belongs in the shared contract only when two adapters can give it the same meaning.
Prove the contract last.
Price is deliberately not the lead criterion. Batch image volume can grow with count, resolution, and retries, so use each candidate's live estimator or billing metadata before a 1,000-item run and set a job budget. Your mileage may vary because creative acceptance, rather than the posted unit alone, drives the useful cost.
Budget the 1,000-item export only after quality passes
Before copying this architecture, measure four things on representative data: accepted-image yield, terminal-state time distribution, retries per accepted image, and provider-specific fields stored outside the adapter. Set pass or fail thresholds before submission, then inspect the exported records rather than grading a few attractive images by eye.
The chosen design wins when the web request stays short, CRM actions remain accurate, and switching the adapter does not require rewriting catalog records. Batch submission is the mechanism. A narrow data boundary is what makes it durable.
Top comments (0)