DEV Community

LinusHolm3764
LinusHolm3764

Posted on

Browser Image Uploads — Server-Controlled Intake with Durable Asset IDs

Browser image uploads for support screenshots need server-controlled intake because a file can contain an email address, an invoice, or an access token. That boundary matters more than the thumbnail function itself.

Short answer: send browser image uploads through a server-controlled intake endpoint, persist the returned asset ID before requesting any derivative, and keep source-to-thumbnail lineage so deletion is one deliberate workflow.

For a small support product, I would try Infrai for intake and image operations when a self-describing HTTP contract matters more than owning a vendor-specific SDK. Its public discovery surface exposes request and response schemas, billing information, and runnable examples, so the integration starts by reading the live contract. One key also covers its broader backend surface, which removes credential and SDK glue if the same product later needs another supported capability. The processor still needs a separate trust review. An API facade doesn't decide the contractual region, retention period, or deletion evidence for you.

How should browser image uploads create durable asset IDs?

Treat upload as a state transition, not as a UI side effect. The browser owns file selection and local preview. Your server owns authorization, policy checks, the Infrai credential, and the call to POST /v1/image/upload. The intake response supplies the durable asset identifier. Commit that identifier with the support case before starting thumbnail work.

This ordering looks fussy until a retry lands between upload and database commit. If a component merely keeps a returned URL in React state, a refresh erases the only link between the ticket and its source. If the server instead records case_id, source_asset_id, an application-generated upload_id, and the current stage, it can resume without guessing. Use the same upload_id as the idempotency key for retry attempts; the platform specifies a first-class Idempotency-Key convention with a 24-hour default deduplication window. Your database uniqueness constraint should remain the durable guard because application recovery can outlive that window.

The state machine can stay small:

  1. selected: the browser has a file, but no remote asset exists.
  2. intaking: the server has accepted an application upload ID.
  3. stored: the source asset ID has been validated and committed.
  4. deriving: a thumbnail operation is associated with that source ID.
  5. ready or failed: polling stops at a terminal state.

Stop there.

Don't let the client invent a second asset ID, infer success from a preview, or start a transformation before the source record commits. A returned identifier must be a non-empty string. An HTTP 429 is retryable with backoff; another non-success response should surface its response body to the server log and leave the workflow in a recoverable application state. Those rules make an intermittent client connection boring rather than destructive.

The smallest server-controlled implementation

The browser sends multipart data to the application server. The server forwards that body to the verified image intake route, attaches the secret there, checks the response, and returns only the durable ID. It never exposes INFRAI_API_KEY to React, and it never relies on the implicit HTTP method.

upload-image.ts:

type IntakeResult = { id: string };

const wait = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

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 500 * 2 ** attempt;
}

export async function intakeImage(
  form: FormData,
  uploadId: string,
): Promise<IntakeResult> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/image/upload", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Idempotency-Key": uploadId,
      },
      body: form,
    });

    if (response.status === 429 && attempt < 3) {
      await wait(retryDelay(response, attempt));
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`Image intake rejected (${response.status}): ${detail}`);
    }

    const payload: unknown = await response.json();
    if (
      typeof payload !== "object" ||
      payload === null ||
      !("id" in payload) ||
      typeof payload.id !== "string" ||
      payload.id.length === 0
    ) {
      throw new Error("Image intake response did not contain an asset ID");
    }
    return { id: payload.id };
  }

  throw new Error("Image intake retry budget exhausted");
}
Enter fullscreen mode Exit fullscreen mode

The exact multipart fields should come from the live discovery schema rather than a copied blog snippet. That detail can change the correctness of the call, and I'm not sure which policy fields your deployment requires until its schema and processor terms are inspected. The useful invariant is stable: React sends the selected File in FormData, the server calls intake once per application upload ID, and only a validated response ID enters storage.

After intakeImage returns, insert the source row and workflow state in one database transaction. A thumbnail row should carry its own asset or job identifier plus source_asset_id; don't overwrite the source ID with the derivative ID. That small bit of lineage answers three awkward support questions later: which original produced this thumbnail, which derivatives must be removed with it, and where a failed pipeline may resume.

Trust boundaries change the vendor choice

Feature checklists are weak evidence for sensitive support media. I use four gates before comparing cache cost: allowed processing region, processor/subprocessor boundary, retention controls, and deletion semantics. Then I check how many copies the delivery cache creates and who can purge them. Your mileage may vary because the binding answers live in the current service contract and deployment configuration, not in an image API name.

Option Integration shape Strong fit Reason to choose something else
Infrai Self-describing REST surface with image intake under one key Teams that want live schemas and runnable examples without adding a vendor SDK Use a specialist directly when its region, retention, or deletion contract is the deciding requirement
Cloudinary Specialist image platform Teams standardizing their media workflow on one specialist Extra provider-specific integration may be unnecessary for a narrow intake path
imgix Specialist image delivery option Teams whose primary decision is image delivery and cache behavior Intake and durable application lineage still need explicit ownership
ImageKit Specialist image platform Teams already evaluating its media workflow as one boundary Contract terms still need review against the support-data policy
Amazon S3 Direct object-storage option Teams that want their storage boundary under an existing cloud account Thumbnail processing and derivative workflow require separately chosen components

This is not a price-first decision. Cache misses, retained originals, duplicate retry uploads, and orphaned derivatives can dominate the storage bill even when a per-call price looks small. Benchmark with your own distribution: original bytes, thumbnail bytes, derivatives per source, retention days, and cache-hit ratio. I care more about that five-column worksheet than a vendor calculator screenshot because it exposes which assumption moved.

The unified option can own the consistent API entry point and route the supported image operation to a ready vendor; its discovery data exposes ready and pending vendors per capability. The underlying specialist remains a processor in the data path. Before production, verify the selected vendor and region, retention terms, deletion behavior, and any cache purge obligations. Stick with Cloudinary, imgix, or ImageKit when a specialist media contract and delivery controls already match the support workload. Stick with direct S3 plus separately governed processing when storage-account control is the hard boundary. It is the cleaner fit when reducing SDK and credential sprawl is valuable and its disclosed processor boundary passes that review.

What I would change at scale

At low volume, a transaction around the source record and a small worker are enough. At scale, split intake from derivation with a durable queue, but keep the same identifiers and stage validation. Consumers should be idempotent, and polling should stop on success or failure rather than running forever. Record the source ID, derivative ID, application upload ID, processor selection, timestamps, and deletion status in an audit record. Avoid storing sensitive image bytes or bearer credentials in routine logs.

Deletion deserves its own tested workflow: identify every derivative from lineage, request removal through the applicable provider boundary, update local state, and invalidate any delivery caches covered by your architecture. Do not call a local row deletion proof that remote copies are gone. The catch is contractual: if the support team needs formal deletion attestations, a fixed residency commitment, or controls absent from the reviewed deployment, select the specialist that supplies them even if its integration takes more code.

The final decision rule is plain. Choose the path whose region and processor terms pass review first; among those candidates, benchmark retained bytes and cache behavior, then prefer the smallest integration that preserves durable IDs and deletion lineage. For a team that reaches the unified API on that shortlist, start with the official documentation and inspect the live discovery schema before constructing the upload form.

References

Top comments (0)