For a bulk seller import that extracts text from fintech product photos, choose queued intake over one long synchronous request. Return a stable batch identifier immediately, track every photo as a separate work item, and count an item as publishable only after both OCR and moderation have reached terminal states. Synchronous processing is still useful for a genuinely small preview, but it is the wrong control plane for a catalog-sized job.
| Option | Pick it when | Progress model | Main trade-off |
|---|---|---|---|
| Queued batch intake | The seller can submit many photos, processing time varies, or moderation can hold an item | Durable batch plus per-item states and ordered events | More state, idempotency, and retention work |
| Synchronous request | A user needs one quick preview and can safely retry the entire operation | One response, possibly with coarse stages | Simple until timeouts, partial completion, or duplicate retries matter |
The decision turns on moderation coverage. OCR completion alone is not success: readable text may still need a policy decision before it enters a catalog used in a financial workflow. Make that gate visible.
How should seller catalog imports expose batch submission progress?
Expose two related views: a compact batch summary for polling and an item ledger for diagnosis. The summary answers "is it done?" The ledger answers "what is holding it up?" Those are different questions, and forcing both into one percentage produces a progress bar that looks precise while hiding the only state an operator actually needs.
A useful batch record has an immutable total, counts for each terminal outcome, an updatedAt timestamp, and a monotonically increasing version. The item ledger carries a seller-provided item key, the media type, the OCR state, the moderation state, and a public outcome code. Keep the seller's key all the way through; otherwise support staff end up translating internal identifiers while a merchant is staring at row 184 of a spreadsheet.
Don't let ocrComplete / total drive publication progress. A photo can be legible and still be awaiting moderation. Instead, define ready as the number published, rejected as the number that reached a documented terminal rejection, and settled as ready + rejected. The honest completion ratio is settled / total. Show OCR coverage separately when it helps operators find the slow stage.
That split matters.
For the item state machine, use explicit states such as accepted, extracting, moderating, published, and rejected. Avoid a single processing bucket. It erases the boundary between text extraction and policy review, which means an alert can tell you the batch is slow but not which queue owns the delay. A diagram in words looks like this: accepted goes to extracting; successful extraction goes to moderating; moderation ends at published or rejected; a retry returns to the same nonterminal stage without creating another catalog item.
Pick queued intake for bulk work
Queued intake fits when the number or size of photos makes completion time uncertain, when a seller may reconnect from another device, or when moderation is asynchronous. Submission should acknowledge durable acceptance, not promise that downstream work finished. The UI can then poll the batch summary or consume ordered events while treating the batch record as the source of truth.
The catch is operational weight. A queue needs deduplication, leases or equivalent ownership, retry policy, event retention, and a way to reconcile counters from the item ledger. It is not suitable when the feature is only a single-image preview with no durable side effect. In that narrow case, stick with a synchronous request and keep the response contract small.
For catalog imports, however, partial completion is normal enough to model directly. One unreadable photo should not erase accepted work from the other rows. Give every submission an idempotency key scoped to the seller and import intent, freeze the batch membership after acceptance, and make retries address an existing item. This keeps a dropped client connection from becoming a duplicate catalog.
Moderation coverage also belongs in acceptance tests. Include fixtures where OCR produces text but moderation remains pending, where moderation rejects one item, and where a duplicate submission reuses the original batch. A batch of 25 fixtures is large enough to exercise mixed states in a local test without pretending that 25 is a production limit or performance benchmark.
Pick synchronous requests for previews
A synchronous path earns its keep for an edit-screen preview: one photo goes in, extracted text comes back, and nothing is published. It is easier to reason about because the request lifetime and work lifetime are the same. It can also provide fast validation feedback for a disallowed media type before a seller starts a bulk upload.
Keep the boundary sharp. If a "preview" begins writing durable catalog rows, waiting on a moderation queue, or accepting multiple items, it has become a job disguised as a request. Move it to the queued path.
Media validation should happen before OCR. Accept only formats that the chosen decoder can actually process, verify the declared type against the bytes, and normalize orientation before extraction. Browser and container support varies by media format, so the allowlist must follow the deployed decoding path rather than a filename extension. The MDN media formats guide is a useful map of format and container concepts, but the final allowlist still needs tests against your own runtime.
Implement a moderation-aware progress ledger
Start with types that make illegal publication visible during review. This example has no vendor SDK and no invented HTTP route. It is the domain layer behind whichever transport the application already uses.
type OcrState = "pending" | "running" | "complete" | "unreadable";
type ModerationState = "blocked" | "pending" | "approved" | "rejected";
type CatalogState = "accepted" | "processing" | "published" | "rejected";
type ImportItem = {
itemKey: string;
ocr: OcrState;
moderation: ModerationState;
catalog: CatalogState;
outcomeCode?: "INPUT_UNREADABLE" | "POLICY_REJECTED";
};
type BatchProgress = {
total: number;
published: number;
rejected: number;
settled: number;
moderationPending: number;
percentSettled: number;
};
function summarize(items: readonly ImportItem[]): BatchProgress {
const published = items.filter((item) => item.catalog === "published").length;
const rejected = items.filter((item) => item.catalog === "rejected").length;
const moderationPending = items.filter(
(item) => item.ocr === "complete" && item.moderation === "pending",
).length;
const settled = published + rejected;
return {
total: items.length,
published,
rejected,
settled,
moderationPending,
percentSettled: items.length === 0 ? 100 : Math.floor((settled / items.length) * 100),
};
}
The code deliberately reports moderationPending beside overall completion. That one counter separates extraction throughput from policy throughput. It also prevents the UI from showing 100% merely because every OCR worker stopped.
Next, guard transitions at the write boundary. A worker may propose a state; the ledger decides whether it is legal. This check is small enough to test exhaustively.
type CatalogDecision =
| { kind: "publish"; extractedText: string }
| { kind: "reject"; code: "INPUT_UNREADABLE" | "POLICY_REJECTED" };
function applyDecision(item: ImportItem, decision: CatalogDecision): ImportItem {
if (decision.kind === "publish") {
if (item.ocr !== "complete" || item.moderation !== "approved") {
throw new Error("PUBLICATION_GATE_CLOSED");
}
return { ...item, catalog: "published", outcomeCode: undefined };
}
return {
...item,
catalog: "rejected",
outcomeCode: decision.code,
};
}
Persist each accepted transition and its batch version in one atomic operation. Then publish an event containing the batch identifier, item key, new public state, and version. Consumers should discard versions they have already applied and refresh the summary if they detect a gap. Events make the interface lively; the ledger repairs missed delivery. That's the crisp division of labor.
Observe the stages, not just the workers. Useful signals include accepted items, terminal items, age of the oldest nonterminal item, transitions rejected by the state guard, and the count awaiting moderation. Attach the batch identifier and a hashed or otherwise non-sensitive seller correlation key to logs and traces; don't put extracted financial text into telemetry. An alert on growing oldest-item age is often more actionable than an alert on queue length because it catches a stranded item even when traffic is low. The exact threshold depends on the service objective and workload, so it should come from measured production distributions rather than a borrowed number.
Test the progress contract as a state machine. Generate sequences with duplicate events, out-of-order delivery, a client reconnect, an unreadable input, and a moderation rejection. Assert that settled never decreases, never exceeds total, and reaches total only when every item is published or rejected. Then test the human view: can an operator identify the owning stage from one batch response? If not, another metric won't rescue the model.
Limits and the final decision
Queued intake does not remove uncertainty; it gives uncertainty a durable address. Retention limits still need to be explicit, event delivery can still be duplicated, and an exact percentage cannot predict when a human or asynchronous policy step will finish. A progress view should say what has settled, not estimate a completion time it cannot defend.
Choose the queue for bulk seller imports with OCR and moderation. Keep synchronous processing for a single, side-effect-free preview. If the team cannot yet operate durable job state, reduce the first release to that preview rather than presenting a long request as batch infrastructure.
Ship the queue when the workflow becomes durable.
References
- MDN Web Docs, "Media container formats (file types)": https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats
Top comments (0)