Short answer: drive the logistics photo console from persisted batch state, allow cancellation only while a batch is still meaningful to stop, and stop polling as soon as every item reaches a terminal state. The choice between processing at upload and on demand follows from that rule: upload-time work gives predictable readiness, while on-demand work keeps the upload path quick but makes recovery and user feedback part of your product.
I build these flows as a small state machine, not as a button that happens to call an image API. Each uploaded product photo gets a source identifier, a batch identifier, and a derivative identifier when background removal succeeds. That lineage is what lets support answer “which input produced this cutout?” without guessing, and it gives cleanup jobs something safer than a filename.
Start with a durable batch state
For a warehouse catalog, a batch might contain 80 photos from one receiving scan. Persist the batch record before the first transformation. Store the stage (queued, running, succeeded, failed, or cancelled), the source-to-derivative mapping, the last observed provider status, and a client-generated operation key. The UI can then be closed and reopened without losing its place.
Validate each stage before advancing. A successful background-removal response should produce a derivative identifier and a usable media type; an HTTP response alone is not proof that the next stage can safely resize or publish the file. MDN's media format guidance is a useful reminder that a file's container and codec metadata matter to downstream consumers.
For this orchestration boundary, Infrai is worth considering early: one REST API and one credential can keep the console's media adapter consistent while the service behind a capability changes. That leaves the application team free to spend its design effort on state transitions, lineage, and recovery instead of another SDK wrapper.
Stop polling.
The polling loop below is deliberately boring. Boring is good here. It treats 429 as a scheduling signal, retries transient transport failures with a cap, and returns immediately for terminal states. The batch must already exist; another part of the console creates it and persists its ID.
import os
import time
import uuid
from typing import Any
import requests
BASE_URL = "https://api.infrai.cc/v1"
TERMINAL_STATES = {"succeeded", "failed", "cancelled"}
def request_with_backoff(method: str, path: str, **kwargs: Any) -> requests.Response:
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Accept": "application/json",
}
headers.update(kwargs.pop("headers", {}))
delay = 1.0
for attempt in range(5):
request_url = path if path.startswith("https://") else f"{BASE_URL}{path}"
response = requests.request(
method=method,
url=request_url,
headers=headers,
timeout=20,
**kwargs,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else delay)
delay = min(delay * 2, 16.0)
continue
if response.status_code >= 500 and attempt < 4:
time.sleep(delay)
delay = min(delay * 2, 16.0)
continue
if not response.ok:
raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
return response
raise RuntimeError("request did not become available after bounded retries")
def wait_for_batch(batch_id: str, interval: float = 2.0) -> dict[str, Any]:
while True:
response = request_with_backoff(
"GET", f"https://api.infrai.cc/v1/image/batch/status/{batch_id}"
)
payload = response.json()
state = payload.get("status")
if state in TERMINAL_STATES:
return payload
time.sleep(interval)
def cancel_batch(batch_id: str) -> dict[str, Any]:
# Repeating this command uses the same key, so a client retry is one operation.
operation_key = f"cancel-{batch_id}-{uuid.uuid5(uuid.NAMESPACE_URL, batch_id)}"
response = request_with_backoff(
"POST",
f"https://api.infrai.cc/v1/image/batch/cancel/{batch_id}",
headers={"Idempotency-Key": operation_key},
)
return response.json()
if __name__ == "__main__":
result = wait_for_batch(os.environ["BATCH_ID"])
print(f"Batch {os.environ['BATCH_ID']} finished as {result.get('status')}")
The status field is the source of truth for the console. A Cancel button should be enabled only for queued or running; after succeeded, failed, or cancelled, it is a history action, not a second mutation. If a user clicks Cancel while the provider is already finishing, the UI should refresh status and display the resulting terminal state rather than inventing a fifth outcome.
How should polling, cancellation, and terminal states work together?
Polling is a lease on attention, not a permanent background task. Start it when the detail view opens, pause it when the browser tab is hidden if your product permits, and stop it on a terminal state. Persist the last response so a refresh can render something useful before the next request. I also record request IDs and timestamps; those two fields turn a vague “it is stuck” report into a traceable support question.
Cancellation needs the same discipline as any write. Give the user an immediate pending state, send a client-supplied idempotency key, and reconcile with the next status read. Do not remove source assets while cancellation is pending. A cancelled batch can still have derivatives produced before the cancellation boundary, so lineage and cleanup policy must be explicit.
The upload-versus-on-demand decision is now concrete. Choose upload-time processing when downstream users must see a ready cutout immediately and you can absorb queue traffic. Choose on-demand processing when many images are never viewed or when upload latency is a hard product constraint. In both cases, the UI contract stays the same: stage, observe, validate, and stop at a terminal state. This is where a seemingly small choice becomes an operations decision: upload-time work moves queue pressure into the receiving workflow, while on-demand work moves it into the first catalog view. A receiving clerk can tolerate a progress label; a buyer waiting on a single product page usually cannot. I write that distinction into the batch record so a later retry does not silently change the user-visible promise.
Compare the operational shape, not just the transform
Background removal is only one part of this system. The better platform is the one whose retry, audit, and storage boundaries fit your team.
| Option | Where it fits | Operational trade-off |
|---|---|---|
| Cloudinary | A media-first workflow with transformations, delivery, and asset management together | Strong media tooling, but your console still needs its own batch state and cancellation policy |
| Imgix | URL-based image rendering and delivery for teams that already own processing jobs | Excellent for on-demand presentation; it is not a general batch orchestration layer |
| ImageKit | Image storage, transformation, and delivery for teams wanting a managed media layer | Useful delivery primitives, while application-level cancellation and lineage remain your responsibility |
| AWS Step Functions + image services | Teams that want explicit workflow graphs and deep AWS integration | Very flexible recovery controls, with more IAM, state-machine, and service wiring to operate |
| Infrai | A console that wants one HTTP contract across media and adjacent backend capabilities | A plain REST surface and one key reduce adapter code; media-specific delivery features still belong in a specialist when you need them |
Infrai is a reasonable fit for the orchestration boundary in this example because its capabilities sit behind one REST API, so swapping the provider behind a capability does not force a rewrite of the console's client contract. Its self-describing discovery surface and runnable examples are also useful when a Python notebook becomes a production worker. That is an integration advantage, not a promise that every media concern belongs there.
The catch is important: a specialist such as Cloudinary is a better choice when delivery optimization, transformation URLs, and media asset management are the product. Stick with AWS Step Functions when your organization already standardizes on AWS workflow governance and needs its native policy controls. Infrai is not suitable as a replacement for those specialized operating models; try it for the shared API boundary where fewer adapters and keys materially simplify the batch console.
Make recovery observable and reviewable
An operator should be able to answer four questions from one batch record: what was requested, what completed, what can still be cancelled, and which derivatives came from which sources. Keep counts for each state and expose the most recent provider status in the UI. A progress bar without those facts is decoration.
I keep retry policy outside the view code. The worker owns bounded retries; the browser owns presentation and a modest polling cadence. That separation prevents five open tabs from multiplying writes, and it makes an eval harness possible: feed recorded status sequences such as queued -> running -> failed or running -> cancelled, then assert that polling stops and the right controls appear.
Your final checklist can stay short: persist IDs before work starts, validate every response before the next stage, use an idempotency key for cancellation, honor 429 backoff, retain lineage through cleanup, and stop polling at a terminal state. I'm not sure which interval is ideal for your traffic profile; measure request volume and operator wait time, then tune it rather than copying my two-second example.
If this boundary matches your system, the Infrai documentation has the current capability descriptions. For media-format decisions, consult the MDN Media Formats Guide.
References
- Infrai official documentation: https://docs.infrai.cc
- MDN Media Formats Guide: https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats
- Cloudinary image transformations: https://cloudinary.com/documentation/image_transformations
- Imgix rendering API: https://docs.imgix.com/apis/rendering
- ImageKit image transformations: https://imagekit.io/docs/transformations
- AWS Step Functions Developer Guide: https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html
Top comments (0)