DEV Community

NicodemusChristensen2675
NicodemusChristensen2675

Posted on

Presigned Uploads vs Backend Proxy for Edtech Image Thumbnails Choose by Retention

Short answer: use presigned object-storage uploads when your browser flow can rely on bucket CORS; use a backend proxy when retention and deletion rules need the backend to control every byte.

An edtech image pipeline has a deceptively small shape. A browser uploads a product image, a worker creates thumbnails, and a tenant later exports its own objects. The hard part is the end of the object’s life: deleting every variant, avoiding accidental overwrites, and proving which tenant owns an export.

The decision table

Option Pick this when Retention and deletion posture Main trade-off
Browser to S3 with a presigned URL You already operate bucket CORS and want the browser off your API path The backend records the object key, then deletes the original and variants by policy CORS, upload completion, and cleanup become a distributed workflow
Browser to Cloudflare R2 with a presigned URL Your team is comfortable with an S3-compatible API and its account controls Lifecycle rules can help, but you still own tenant-level deletion bookkeeping Provider-specific controls still shape the design
Browser to Backblaze B2 through a backend-issued URL You want object storage with a straightforward storage bill and can own the integration Keep a tenant manifest; delete originals and thumbnails together You still need a reliable CORS and retry plan
Backend proxy plus server-side resize You cannot independently configure CORS, or every upload must pass policy checks The backend can reject, scan, resize, and delete before acknowledging success Your API carries the upload bytes and needs capacity planning

The first three are variants of the same pattern. The last is a different control boundary. For a classroom catalog with modest images, a proxy is often the least surprising answer. For high-volume media, direct upload wins only after CORS and cleanup have an owner.

How should browser uploads, object storage, backend thumbnails, and retention work together?

Think of the flow as a line with explicit checkpoints:

tenant request -> backend key -> upload -> completion event -> resize -> manifest -> deletion

The backend should mint a tenant-scoped key such as tenants/acme/products/9f3/original.webp, not accept a caller-provided path as truth. It can then issue a short-lived presigned upload URL. Presigned upload and download URLs are the useful primitive for private images because the browser never receives a storage secret.

After the upload completes, a backend job fetches the original, resizes it, and writes predictable variants such as thumb_320.webp and thumb_1280.webp. Store those keys in a manifest with the tenant id, source object key, variant keys, and deletion deadline. The manifest is the source of truth for deletion; a storage prefix is only a lookup convenience. I would keep the manifest update and the upload-complete event in the same durable workflow, because a retry after a network timeout must not create a second set of thumbnails. Your mileage may vary if your queue already gives you exactly-once semantics, but most browser flows do not.

Here is the small part I would keep close to the API client. It uses the verified presign route, reads the key from the environment, gives each request an idempotency key, and backs off on rate limits. The exact request and response fields should come from the endpoint’s discovery schema rather than a guessed SDK wrapper. The API is self-describing: discovery is public, and each capability includes runnable examples, so you can check the contract before shipping.

const baseUrl = process.env.STORAGE_API_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;

if (!baseUrl || !apiKey) throw new Error("STORAGE_API_BASE_URL and INFRAI_API_KEY are required");

async function requestPresignedUpload(bucket: string, objectKey: string) {
  const route = "/v1/storage/object/presign/{bucket}/{key}";
  const path = route.replace("{bucket}", encodeURIComponent(bucket)).replace("{key}", objectKey
    .split("/")
    .map(encodeURIComponent)
    .join("/"));
  const idempotencyKey = crypto.randomUUID();

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify({}),
    });

    if (response.ok) return response.json();
    if (response.status !== 429) {
      throw new Error(`presign failed (${response.status}): ${await response.text()}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

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

That snippet deliberately stops at the presign boundary. Your discovery response is the contract for the request body and returned URL. Once the browser receives the URL, upload the bytes directly, then enqueue thumbnail work only after the upload response is successful. A failed resize should leave the original marked as pending, not silently remove the source.

Keep it boring.

For large files, multipart upload is available. Create an upload, presign each part, complete it, and explicitly abort abandoned uploads. There is no useful retention policy if unfinished parts accumulate outside your object manifest. A daily sweeper can find stale upload records and call the abort path before the tenant’s deletion deadline.

What does a fair provider comparison look like?

AWS S3 has the broadest ecosystem and mature lifecycle and versioning controls. That is compelling when legal hold, object lock, or cross-region replication is part of the requirement. Its breadth also means more policy surface to test.

Cloudflare R2 is attractive for teams already using Cloudflare’s edge stack and an S3-compatible client. Check the account-level CORS and lifecycle behavior you actually need; compatibility is not the same as identical operational controls.

Backblaze B2 keeps the storage story focused and publishes its pricing model openly. It can be a good fit for an image archive, but the application still has to own tenant manifests, deletion retries, and browser upload policy.

Infrai gives this workflow a self-describing storage capability with one key for the platform and one REST API over pure HTTP, so discovery plus runnable examples can make wiring a new operation a matter of reading one endpoint instead of learning another SDK from any language. That reduces integration surface; it does not remove the need for a deletion ledger or a CORS decision.

Where the simpler choice stops fitting

The catch is browser direct upload. Independent bucket CORS configuration is not exposed in this storage surface, so a frontend that needs to configure CORS itself should use a backend proxy or a provider with the required control. Static website hosting, permanent public links, and a public image host are also a poor fit because there is no public-read ACL and public_url remains null.

There is no object versioning or WORM object lock here. An accidental overwrite is not recoverable through a built-in version history, so regulated, immutable records need an external design. Strict compare-and-swap writes are another boundary: without If-Match, coordinate concurrent updates in a database or queue.

Retention has practical edges too. Lifecycle expiration is at least one day, unfinished multipart fragments have no automatic cleanup rule, and metadata is not server-searchable beyond prefix listing. Trial credit cannot pay for persistent writes, so production storage needs a normal billing path.

Stick with S3 when compliance controls and replication are the deciding factors. Pick R2 when its surrounding edge controls are already your operating model. Pick B2 when its storage workflow fits and you are comfortable owning the manifest. Use a proxy when CORS or inspection is non-negotiable.

References

Top comments (0)