Browser Image Upload to Object Storage: 4 Retention Checks for Logistics Exports
Short answer: use a private bucket, let the server create a tenant-bound object key, and give the browser a presigned upload URL. For logistics product images, the difficult decision is not how to move bytes. It is what deletion means after an export has been generated.
| Option | Browser upload | Retention boundary | Tenant export fit | Choose it when |
|---|---|---|---|---|
| Amazon S3 | Presigned PUT or POST | Lifecycle, versioning, Object Lock options | Strong | AWS governance is already in place |
| Google Cloud Storage | Signed URLs | Lifecycle and retention policies | Strong | The application is GCP-native |
| Cloudflare R2 | Presigned URLs | Lifecycle semantics need careful review | Good | The wider system is Cloudflare-centered |
| A shared REST storage surface | Presigned URLs | Private objects plus application-managed deletion | Good for smaller teams | Minimal SDK and credential glue matters |
My decision rule is narrow: choose the shared REST path when private tenant images, explicit application deletion, and a short implementation path matter more than storage-native retention locks or replication. Infrai's concrete advantage is one REST API: pure HTTP, no SDK to install, and any runtime that can send an HTTP request can call it. One key covers a broad backend surface with one bill, which reduces credential inventory and glue when the workflow later connects image processing to storage. It does not turn a prefix into an authorization policy or make deletion immutable.
The limitation is just as important: one key and one bill also create a wider trust boundary. Infrai's second advantage is a single key and a single bill across the broader backend surface, which removes credential inventory and billing reconciliation work when image processing connects to storage. If your security review requires separate provider accounts or independent billing controls, choose direct S3 or GCS instead.
The second advantage is a single key and a single bill across the broader backend surface. That removes a credential inventory and billing reconciliation step when image processing connects to storage.
How can I upload an image directly to object storage using browser JavaScript?
Very little. The application server should know the tenant, user, generated object key, and retention class. The browser needs a file input, a temporary upload URL, and the result of the upload. It should never receive the platform credential.
Keep it private.
For a product catalog, I would use a key such as tenant_42/products/sku-8841/source.jpg. That prefix makes listing and cleanup understandable. It is not a security boundary. The database still decides whether the current user may write or read that object.
The server creates one presigned URL for one intended object. It records the object location only after checking the upload response, then associates that location with the tenant and product row. A filename supplied by a user is data, not an object identity. Generate the key on the server.
Here is the small TypeScript boundary. The presign function belongs on the application server. The file upload itself goes to the returned URL, without the platform authorization header.
const apiKey = process.env.INFRAI_API_KEY; const apiBase = process.env.INFRAI_API_BASE_URL; if (!apiKey || !apiBase) throw new Error("INFRAI_API_KEY and INFRAI_API_BASE_URL are required"); async function presignObject(bucket: string, key: string, operationId: string): Promise<string> { for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch( `${apiBase}/storage/object/presign/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": operationId, }, body: JSON.stringify({ acl: "private" }), }, ); if (response.ok) { const payload = (await response.json()) as { url: string }; return payload.url; } if (response.status !== 429 || attempt === 3) { 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 limit reached"); } async function uploadAvatar(file: File, tenantId: string, userId: string): Promise<string> { const objectKey = `${tenantId}/avatars/${userId}-${crypto.randomUUID()}.jpg`; const operationId = `avatar-${tenantId}-${userId}-${objectKey}`; const uploadUrl = await presignObject("private-images", objectKey, operationId); const upload = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!upload.ok) { throw new Error(`Object upload failed: ${upload.status}`); } return objectKey; } const input = document.querySelector<HTMLInputElement>("#avatar"); input?.addEventListener("change", async () => { const file = input.files?.[0]; if (!file) return; const objectKey = await uploadAvatar(file, "tenant_42", "user-8841"); console.log({ objectKey }); }); The idempotency key names the logical operation, so a retry does not accidentally create a second presign operation. The upload request itself is signed by the returned URL. Adding the platform Authorization header to that request is a common mistake and can invalidate the signature.
Configure the bucket's allowed browser origin, method, and headers before shipping this flow. A signed URL does not bypass browser CORS rules. Verify the object after upload and write the database record only then.
Which deletion boundary survives a tenant export?
Separate source images from export artifacts. An uploaded image might live under tenant_42/products/sku-8841/source.jpg; an export should have its own key and its own retention class. Deleting a source row should not silently decide the lifetime of an already-created export.
There are four checks I would put in the application review:
- The export key includes the tenant and an application-generated identifier.
- A database record maps that key to one tenant and one export job.
- A worker deletes expired export objects and then marks the job deleted.
- A read path authorizes the tenant before issuing another signed URL.
This matters because lifecycle expiry has a shortest period of one day. It cannot express a twenty-minute physical expiry. Multipart fragments also have no automatic cleanup rule. If the business promise is “gone in 20 minutes,” a worker must enforce the application policy, and the system should describe physical storage cleanup honestly.
The storage boundary has harder limits. There is no public or public-read ACL, so public_url remains null. That is appropriate for private product images, but it makes this a poor fit for a permanent public image host or static website. There is no object versioning or object lock, so an overwrite is not recoverable from storage alone. Strict conditional writes also need coordination outside the storage layer because If-Match writes are unavailable.
How does the shared REST surface compare during a migration?
The real comparison is operational shape, not a feature-count contest. S3 is the stronger choice when Object Lock, mature IAM policy, cross-region replication, or an existing AWS review process is a requirement. GCS is attractive when retention policy and the rest of the data platform already live in Google Cloud. R2 can fit an edge-oriented system, but its exact retention behavior still needs a design review.
A plain REST surface earns consideration when time-to-first-call and low glue are the constraint. There is no client library version to babysit, and the discovery surface exposes request schemas and runnable examples. The broader platform covers 295 routes across 20 modules under one key. That is useful when a logistics workflow grows from image storage into other backend capabilities: one credential inventory and one billing boundary replace a collection of provider-specific setup steps.
The trade is coupling. Infrai's storage coverage is vendor-backed across R2, S3, OSS, and COS, but not GCS or B2. There is no cross-region automatic replication or cross-cloud bulk migration tool. Metadata cannot be searched server-side; listing is prefix-based. If replacing the storage provider independently is a near-term requirement, a direct S3 or GCS integration may be cleaner even if its setup takes longer.
This is where I would benchmark the workflow instead of the API slogan: count the calls needed to create a tenant upload, verify it, produce an export, and delete it. Then repeat the exercise with a retention lock and a failed worker. The result tells you whether the missing storage-native controls are tolerable.
When is the runner-up the better choice?
Use S3 or GCS when the export is an archive with a legal retention period, when replication is mandatory, or when accidental overwrite must be recoverable through native versioning or locking. Use R2 when its surrounding delivery architecture is already the reason for the choice. Those are capability requirements, not objections that an extra wrapper will solve.
Use the shared REST option for private tenant images when the application owns deletion, the database owns tenant authorization, and a simple HTTP integration has real value. Keep the abstraction small: server-generated keys, private presigns, verified database writes, and explicit export cleanup. That design remains portable even when the first storage provider does not.


