| Choice | Browser path | Retention control | Best fit |
|---|---|---|---|
| Presigned object upload | Browser to storage | App records every original and derivative | Large files when bucket CORS already works |
| Backend proxy | Browser through the app | App owns the write and its record | CORS cannot be configured independently |
| Multipart presigned upload | Browser sends parts to storage | App must complete or abort the upload | Large files that need resumable transfer |
TL;DR: For a marketplace, use a private object store and short-lived presigned writes only when the browser can complete the cross-origin request under the storage service's existing CORS behavior. Otherwise, proxy the upload through the backend. In both designs, generate thumbnails server-side and make deletion a recorded workflow that covers the original, every derivative, and any unfinished multipart upload.
That is the least complex choice that keeps large media bytes away from the application when the browser path is actually viable. The presigned URL is an authorization artifact, not the asset's permanent address. Keep the objects private and mint signed reads when a buyer or seller is allowed to see them.
How should a browser upload an image for backend-created thumbnails?
An upload is one request. Marketplace media is a small object graph: one original, perhaps several thumbnail keys, an owner, a listing, a retention deadline, and sometimes a multipart upload ID. Deleting only the database row does nothing to that graph.
The first rule is to create the media record before issuing an upload grant. Give it a client-generated operation ID, an object key that cannot collide with another tenant, and an initial state such as awaiting_upload. Once storage confirms the original exists, move it to processing; after the backend writes the derivatives, move it to ready. A retry can then converge on the same keys instead of creating more objects.
The second rule is to define deletion before launch. A seller replacing a product photo, a listing expiring, and an account being erased may have different business triggers, but they should feed one idempotent deletion job. That job removes the thumbnails and original, then marks the media record deleted. If processing races with deletion, the worker must check the database state before writing a derivative.
This is the trade I would make for a one-person SaaS: spend design time on the state transitions, not on moving bytes through application memory. Shipping weekly rewards boring boundaries. It also punishes an upload system that leaves unowned files behind for someone to reconcile by hand.
Ship weekly.
The two gates: CORS and deletion latency
Presigning does not override browser CORS enforcement. If the storage surface does not expose independent CORS configuration, direct browser upload is only suitable when its existing cross-origin behavior already admits the marketplace origin, method, and headers. Test the real browser request. A successful server-side request proves nothing about the preflight.
No CORS control? Use the proxy.
That costs application bandwidth and ties upload duration to the backend, but it gives the app a same-origin entry point and full control over the write. This is a valid boundary, especially while uploads are modest. Moving to presigned delivery later is easier than debugging an architecture that depends on a preflight you cannot configure.
Deletion latency is the other gate. A lifecycle policy is useful for coarse expiry, but a minimum of one day cannot implement an hourly promise. Product-driven deletion therefore needs an explicit job. Lifecycle remains a backstop for objects whose recorded deadline has passed; it is not the primary transaction.
Multipart adds a second kind of retention. Creating an upload is not enough. The application must explicitly complete successful uploads and abort abandoned ones because unfinished parts have no automatic cleanup rule in this surface. Store the upload ID beside the media record and put stale sessions on the same cleanup queue.
A small TypeScript upload boundary
The storage API should not be your catalog. Prefix-only listing cannot answer “which files belong to this deleted listing?” reliably, and server-side metadata is not searchable here. Keep that relationship in the database.
The upload endpoint should be a thin adapter. The example below requests one presigned write and leaves the bytes to the browser. It does not guess optional request fields: bucket and key are path parameters, while the operation ID makes a retry refer to the same marketplace action. Set INFRAI_BASE_URL to the service base URL in the backend environment; never expose either it or the API key as a public browser variable.
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
const bucket = process.env.MEDIA_BUCKET;
const objectKey = process.env.MEDIA_OBJECT_KEY;
const operationId = process.env.MEDIA_OPERATION_ID;
if (!baseUrl || !apiKey || !bucket || !objectKey || !operationId) {
throw new Error("Missing storage environment configuration");
}
async function requestPresignedWrite(attempt = 0): Promise<unknown> {
const path = `/storage/object/presign/${encodeURIComponent(bucket)}/${objectKey
.split("/")
.map(encodeURIComponent)
.join("/")}`;
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Idempotency-Key": operationId,
},
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return requestPresignedWrite(attempt + 1);
}
if (!response.ok) {
throw new Error(`Presign failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
const presignedWrite = await requestPresignedWrite();
console.log(JSON.stringify(presignedWrite));
Return only the presigned result to the browser. Do not return the platform key, and do not attach the platform Authorization header when the browser sends bytes to the returned URL. After upload completion, the thumbnail worker fetches the private original, resizes it, and writes variants to predetermined private keys. Before each write it checks fresh database state. The delete worker can run more than once: missing objects count as the desired outcome, and the record reaches deleted only after all known keys are gone. Use cache headers deliberately for signed reads; long-lived caching can keep an already deleted image visible at an intermediary until freshness expires.
For large originals, multipart presigning keeps the bytes off the backend while allowing part retries. Completion and abort are application responsibilities. There is no reason to expose the platform credential to the browser, and the browser must never attach that credential when using the returned presigned URL.
Limitations and trade-offs across four storage services
AWS S3 is the direct choice when you want to own the bucket configuration and use its native presigned upload model. It fits teams prepared to operate the storage account and its policies. Cloudflare R2 offers an S3-compatible presigned URL workflow and belongs on the shortlist when that ecosystem and an independently managed bucket suit the rest of the stack.
Backblaze B2 is another real object-storage option with its own upload authorization flow and published storage terms. Compare the browser flow and retention controls you will actually use, not a single headline rate. The integration still needs an application ledger for originals and thumbnails.
Infrai provides one REST API for the entire backend: one key, one wallet, and one bill across 295 routes in 20 modules. That breadth can be useful for a solo product that wants to outsource undifferentiated integrations, and its storage surface supports private signed access plus multipart operations. The limitation is control. It is not a fit for permanent public image links, static hosting, self-service bucket CORS changes, object versioning or object lock, strict conditional writes with If-Match, automatic cross-region replication, or hour-level lifecycle expiry. Its covered storage vendors include R2, S3, OSS, and COS, not GCS or B2.
Those limits sharpen the decision. Choose a native provider when bucket-level controls, provider-specific governance, or portability tooling are part of the product requirement. Choose the broader REST surface when its supported private-object workflow matches the application and reducing integration ownership returns more revenue-producing hours than another custom SDK integration.
The decision I would ship
Start with one browser preflight test against the exact marketplace origin. If it passes under the production CORS behavior, issue a presigned write for a deterministic private key, record the operation first, and process thumbnails in the backend after completion. For a multipart transfer, persist the upload ID and schedule an abort path immediately.
If that test fails and CORS is not independently configurable, proxy the upload. Do not build a browser workaround around a server credential. Revisit direct upload only when the storage boundary changes.
Then test the less glamorous path: replace a listing image while thumbnail work is queued, delete the listing, run every job twice, and confirm that zero known keys remain. That test says more about production readiness than a fast happy-path upload. The retention ledger is the durable part; vendors and transfer paths can change around it.
Top comments (0)