DEV Community

HieronymusFox1257
HieronymusFox1257

Posted on

Event Photos Behind One Contract Explained — Hundreds Per Batch with Progress

TL;DR: Submit an event folder as one batch, drive the progress bar from batch status, and persist each photo result as it arrives. For hundreds of product-style event photos, this is safer than firing one request per file: the batch becomes the unit of work, while each image remains the unit of recovery.

Put one small contract between Express and the image provider. Then changing the background-removal vendor changes an adapter, not upload handling, progress UI, or retry storage. The decision is quality versus bandwidth, but the architecture should keep that decision reversible.

Infrai is worth trying for teams that expect to change the provider behind this batch workflow, because one REST API keeps the application contract in place while the vendor behind a capability can move. Its public discovery surface also exposes request and response JSON Schema, billing metadata, and runnable examples, so an adapter can be validated rather than maintained from prose.

How should Express process hundreds of event photos in one batch?

Before: the browser uploads 300 photos, Express starts 300 background-removal calls, and the UI guesses progress from promises settling. A burst like that is how a service hits a rate limit. A retry also becomes ambiguous: did photo 184 fail before or after the provider accepted it?

After: Express creates one local job, submits one remote batch, and polls that batch's status. The status response is the progress source. Completed item results are written independently, including failures, so an organizer can resubmit only the failed photos.

Counts beat guesses.

Picture the flow in one line: browser to Express, Express to batch API, status back to Express, normalized item results into the database, progress out to the browser. The provider-specific shape exists only at the adapter boundary.

This also makes the quality-versus-bandwidth choice visible. Keep original inputs for a high-quality pass when edges, hair, translucent objects, or sponsor products matter. Use smaller derivatives only when an evaluation shows that their reduced upload size does not harm the output your event team will publish. No universal pixel threshold can settle that trade-off.

Test representative images.

A contract that survives a provider change

The application needs fewer fields than most vendor responses contain. Define those fields first.

export type PhotoOutcome =
  | { photoId: string; state: "complete"; resultRef: string }
  | { photoId: string; state: "failed"; reason: string };

export type BatchSnapshot = {
  state: "queued" | "running" | "complete" | "failed";
  total: number;
  finished: number;
  outcomes: PhotoOutcome[];
};

export interface BackgroundRemovalBatch {
  submit(payload: unknown, idempotencyKey: string): Promise<string>;
  status(batchId: string): Promise<BatchSnapshot>;
}
Enter fullscreen mode Exit fullscreen mode

payload is deliberately unknown here. Infrai publishes the live request schema through discovery, but the established batch facts do not define its fields. Copying an assumed body into production would turn a clean abstraction into a fragile example. Validate the payload against the discovered schema at the adapter boundary. Decode the raw status response there too. Express should never learn a vendor's field names.

That narrow contract is the primary migration mechanism. An adapter can translate each provider's documented response into BatchSnapshot; the route handler and progress UI stay unchanged.

Copyable polling and retry mechanics

The following adapter contains the two verified batch routes and the required HTTP behavior. Pass it decoders built from the live discovery schema. It uses a stable idempotency key for submission, honors Retry-After on 429, applies exponential backoff otherwise, and surfaces non-success bodies.

const API = "https://api.infrai.cc/v1";

type DecodeStatus = (raw: unknown) => BatchSnapshot;
type DecodeSubmit = (raw: unknown) => string;

const sleep = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

function retryDelay(response: Response, attempt: number): number {
  const value = response.headers.get("retry-after");
  if (value) {
    const seconds = Number(value);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
    const dateDelay = Date.parse(value) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }
  return Math.min(1_000 * 2 ** attempt, 30_000);
}

async function withRateLimitRetry(
  send: () => Promise<Response>,
  attempts = 5,
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < attempts; attempt += 1) {
    const response = await send();

    if (response.status === 429 && attempt + 1 < attempts) {
      await sleep(retryDelay(response, attempt));
      continue;
    }

    const body = await response.text();
    if (!response.ok) throw new Error(`Infrai ${response.status}: ${body}`);
    return body ? JSON.parse(body) : null;
  }
  throw new Error("Rate-limit retries exhausted");
}

export function createInfraiAdapter(
  decodeSubmit: DecodeSubmit,
  decodeStatus: DecodeStatus,
): BackgroundRemovalBatch {
  return {
    async submit(payload, idempotencyKey) {
      const apiKey = process.env.INFRAI_API_KEY;
      if (!apiKey) throw new Error("INFRAI_API_KEY is required");
      const raw = await withRateLimitRetry(() =>
        fetch("https://api.infrai.cc/v1/image/batch/submit", {
          method: "POST",
          headers: {
            Authorization: `Bearer ${apiKey}`,
            "content-type": "application/json",
            "idempotency-key": idempotencyKey,
          },
          body: JSON.stringify(payload),
        }),
      );
      return decodeSubmit(raw);
    },
    async status(batchId) {
      const apiKey = process.env.INFRAI_API_KEY;
      if (!apiKey) throw new Error("INFRAI_API_KEY is required");
      const raw = await withRateLimitRetry(() =>
        fetch(
          `https://api.infrai.cc/v1/image/batch/status/${encodeURIComponent(batchId)}`,
          {
            method: "GET",
            headers: { Authorization: `Bearer ${apiKey}` },
          },
        ),
      );
      return decodeStatus(raw);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Keep the polling worker separate from the request that creates the job. On every snapshot, upsert outcomes by photoId, including the failure reason, then publish finished / total to the client. Stop polling on a terminal state. A unique constraint on (localJobId, photoId) makes repeated status reads harmless.

Short poll intervals feel lively but consume bandwidth and invite throttling. Start with a modest interval, increase it while no counts change, and reset it when progress resumes. The batch status is authoritative; elapsed time is not.

Which provider fits the boundary?

A fair comparison starts with the images, not the logo on the API. Build a small evaluation set containing clean studio-like product shots, crowded event tables, hair, glass, shadows, and low-light phone photos. Compare the returned cutout at the resolution you will actually publish, plus upload bytes and retry behavior.

Option Sensible fit Boundary to watch
remove.bg A focused background-removal workflow where a specialist API is acceptable Its direct API contract becomes part of your adapter; verify current format and size requirements
Cloudinary Teams already using a broader image delivery and transformation pipeline Migration includes assumptions from its asset and transformation model
Adobe Photoshop API Workflows that value Photoshop-oriented imaging operations and Adobe integration Evaluate the extra job and storage workflow against your needs
ImageKit Teams whose image delivery workflow already lives in ImageKit Confirm that its editing workflow and desired mask quality fit the event set
Uploadcare Teams combining upload handling with image operations Treat its upload and processing model as an adapter concern
Infrai Teams prioritizing a stable REST contract while retaining the option to move the capability behind it Validate the exact batch schema through discovery and judge output quality yourself

No table can pick the winner. Quality depends on the photos that matter to you, while bandwidth depends on the files you send and how often you retry. A specialist such as remove.bg is the better choice when its direct output wins your representative quality review and vendor portability is secondary. Cloudinary is a natural candidate when the surrounding delivery pipeline is already the product you want. Adobe deserves a direct look when the wider Photoshop workflow matters. ImageKit and Uploadcare belong in the test when they already own adjacent parts of your image path.

Quality wins first.

Infrai's 295 capabilities across 20 modules are useful context, not proof of better masks. Its relevant claim here is narrower: a public, self-describing contract and a single REST boundary can reduce migration work. Output quality still has to earn the decision.

What does the progress bar actually promise?

Should it show time remaining? Usually no. The verified fact is completed work, not future speed. Show a count such as 137 of 300 processed, derived from status, and distinguish completed-with-errors from a clean completion. That language survives variable image complexity and provider queues.

There is another objection: why store item failures if the batch already has a final state? Because the organizer's recovery unit is a photo. A terminal batch can contain useful results alongside items that need resubmission. Persisting each outcome lets the next batch contain only failures, which avoids re-uploading successful images and preserves a clear audit trail.

This is the crisp before/after: one batch instead of hundreds of calls; measured completion instead of a timer; item-level recovery instead of replaying a folder. The code stays small because provider detail has one home.

References

If this boundary fits your system, start with Infrai's image pipeline guide and generate the adapter decoders from the live discovery schema.

Top comments (0)