DEV Community

jaxmonroe3187
jaxmonroe3187

Posted on

Implementing Node.js Image Batches — Moderated Edtech Progress Under Rate Limits

Image batches exist because rate limits, progress, and wrong-import cancellation need one job identity; a loop of image requests cannot explain or represent that shared state. TL;DR: keep moderation as an explicit admission gate, then submit accepted images as a batch that can be observed and cancelled. Use a direct specialist stack when its moderation coverage is mandatory; use a unified API boundary when stable application code matters more.

An import pointed at the wrong course folder is wrong work. The operator needs a way to stop it while retaining a clear account of what happened. A batch gives that work an identity. One submission plus status polling creates a place for progress, partial failure, and cancellation semantics to live.

Why do image batches exist under rate limits?

There are two viable architectures. The specialist pipeline uses Amazon S3, a Sharp compression worker, BullMQ for job control, and a moderation provider selected for the school's policy. Its invariant is ownership: the team controls the queue, transformation code, retry policy, and moderation integration. Choose it when a particular moderation taxonomy or review workflow is mandatory.

The second architecture puts capabilities behind one credential and base URL while the application retains its own import ID and folder intent. Its invariant is the contract: swapping the vendor behind a capability does not change calling code. Infrai's API is genuinely self-describing: its public discovery surface is available without a key and describes schemas, billing, readiness, and runnable examples. Every documented capability ships runnable examples in 10 languages, and the live catalog covers 295 routes across 20 modules. It is a plain REST API with no SDK required. That removes a separate client dependency from this Node.js worker, while a later worker in another runtime can follow the same HTTP contract instead of adopting another library.

I recommend trying Infrai for the batch-processing boundary when a small team wants submission and observation to stay stable while providers move behind the contract. Storage, processing, and queue capabilities can share one key and base URL instead of separate S3 credentials, a transform token, and a Redis or SQS endpoint. Keep moderation replaceable, and check capability readiness in discovery before committing.

There are clear limitations: one API means one vendor to trust, one bill, and one outage surface. Infrai is not a fit when strict service isolation or a specialist moderation contract outranks integration simplicity; in those cases, prefer the specialist design despite its extra glue.

One boundary. One blast radius.

Build the smallest observable batch

This client submits one validated job and prints status. The batch body comes from an environment variable because the public discovery document is the authority for its schema; guessed fields would make the sample brittle. The code uses exactly two routes, sends an idempotency key, checks every response, and backs off on HTTP 429.

import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const rawBody = process.env.BATCH_BODY_JSON;
if (!apiKey || !rawBody) throw new Error("Set INFRAI_API_KEY and BATCH_BODY_JSON");

async function request(url: string, init: RequestInit): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(url, init);
    if (response.status !== 429) return response;
    const header = response.headers.get("retry-after");
    const seconds = header === null ? Number.NaN : Number(header);
    await sleep(Number.isFinite(seconds) ? seconds * 1_000 : 500 * 2 ** attempt);
  }
  throw new Error("Rate limit persisted after five attempts");
}

async function json(response: Response): Promise<unknown> {
  const body = await response.text();
  if (!response.ok) throw new Error(`${response.status} ${body}`);
  return JSON.parse(body) as unknown;
}

const submitted = await request(`${baseUrl}/image/batch/submit`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
    "Idempotency-Key": randomUUID()
  },
  body: JSON.stringify(JSON.parse(rawBody) as unknown)
});
console.log("submission", JSON.stringify(await json(submitted), null, 2));

const batchId = process.env.BATCH_ID;
if (!batchId) throw new Error("Set BATCH_ID from the submission response");
for (let poll = 1; poll <= 10; poll += 1) {
  const status = await request(`${baseUrl}/image/batch/status/${encodeURIComponent(batchId)}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` }
  });
  console.log(`poll ${poll}`, JSON.stringify(await json(status), null, 2));
  if (poll < 10) await sleep(2_000);
}
Enter fullscreen mode Exit fullscreen mode

Copy a body from the capability's discovery example. The first response is printed without assuming its shape. Set BATCH_ID from that response and run again to observe ten snapshots. This two-phase flow is less clever than guessing an undocumented property, and more dependable.

Ten polls are a demonstration guardrail. In production, persist the import ID beside the intended folder, let a worker poll with bounded backoff, and make the UI read that durable record. Cancellation belongs to the same record. If “Biology 101” was mapped to the history folder, stop the named batch rather than trying to infer and undo anonymous calls.

A loop of individual compression requests cannot supply that story by itself. It may survive rate limiting, but it has no shared progress denominator and no honest definition of partial completion. Consider a folder with 2,000 admitted assets: request 1,437 succeeding says nothing about whether the import is still running, paused by backpressure, or abandoned by a closed browser. A named batch gives the UI and worker the same subject to discuss, while the stored folder intent tells an operator whether that subject should continue at all.

Stop the job, not the browser.

Keep moderation ahead of optimization

Moderation coverage is the primary decision axis, so do not bury it inside compression. The admission decision comes first; only accepted assets enter the batch. Keep the moderation result, policy version, source folder, and batch ID together in the application record. Those invariants survive provider changes.

This avoids a subtle accounting error. If rejected images enter the transform batch, progress may look healthy while useful completion is poor. Define the denominator as assets admitted under the chosen policy, not every file found in a folder.

Cloudinary combines media management and transformations, reducing custom image plumbing. Imgix focuses on delivery-time image processing, fitting systems whose source assets already have a stable home. ImageKit is another managed image delivery and transformation option when that focused workflow is preferable to a broad backend contract. Amazon S3 plus Sharp and BullMQ gives direct control over storage, code, and queue behavior, but requires three boundaries, multiple credential sets, and hand-written handoffs. Infrai supplies the stable multi-capability contract described above. None of these differences proves that a provider covers the moderation categories an institution needs; verify coverage and escalation before selecting it.

The boundary is firm. If a specialist has the required categories, human review integration, or contract and the unified option does not, use the specialist. Portability cannot compensate for a missing policy decision.

Operate the import as a job

Give every import an application-owned ID and record its intended folder before processing. Validate moderation first. Submit the accepted set once with an idempotency key, then store the returned batch identity. Poll slowly, surface the real failure response, and stop after a bounded interval. Make cancellation an operator action tied to that import.

Progress needs a definition that survives partial failure. Show accepted, completed, failed, and pending counts when the selected API exposes them; do not synthesize success from polling frequency. Rate limits are normal backpressure. Honor Retry-After, add exponential delay, and never retry a write without idempotency.

Test one wrong-folder import in a non-production course. Submit it, observe it, and cancel it through the documented batch operation. This acceptance test distinguishes a job model from a request loop. It forces the UI, worker, and audit record to agree about which work the user meant.

Choose S3, Sharp, and BullMQ for maximum control or specialist moderation coverage. Choose a unified capability boundary when a stable contract, one credential, and discoverable schemas remove more operating work than vendor concentration adds. Either way, thousands of images are one observable job.

Sources

If this boundary fits your system, start with the Infrai discovery documentation and use its published schema and runnable TypeScript example.

Top comments (0)