A valid presigned PUT is not enough for a browser upload. The bucket must also accept the browser's preflight request, and a storage layer without self-service CORS configuration leaves that part outside your control. For a receipt-processing SaaS, send the generated image or original receipt through your Node.js backend and write it to private object storage there. It is the simplest reliable path, and it keeps the audit original away from a public URL.
| Path | CORS exposure | Large-file behavior | Best fit |
|---|---|---|---|
| Browser to a presigned PUT | Bucket CORS must match the browser request | Removes application-server bandwidth | A bucket you control and can configure |
| Node.js proxy to a private object PUT | No browser-to-storage preflight | Your server carries the bytes | Straightforward receipt and generated-image ingestion |
| Server-side multipart upload | No browser-to-storage preflight | Parts limit peak memory and support large objects | Large files or unreliable upstream connections |
Short answer: start with the Node.js proxy. Move to server-side multipart upload when object size, memory pressure, or retry cost shows that one PUT is no longer acceptable. Do not make storage pricing the deciding factor; engineering hours spent debugging an upload path compete directly with the feature I could ship this week.
Should generated images upload direct from the browser to object storage?
These failures look similar in a product UI. They are not the same failure.
A browser can send an OPTIONS preflight before the PUT. The storage origin has to allow the web application's origin, method, and requested headers. If that exchange is rejected, JavaScript may expose little more than a generic network error. A correctly signed PUT can still be unusable from that page.
Run one controlled comparison: upload the same bytes with the same object key from the backend. If the backend PUT succeeds while the browser path stops at preflight, investigate CORS. If the backend request also fails, inspect the status and response body for an authentication, path, or signature error. This split is faster than repeatedly changing signed headers in the browser and hoping.
There is an important boundary here. Infrai provides private object storage and presigned URLs for later reads, but this workflow does not offer self-service control of the bucket CORS rules. Its useful angle is the plain REST interface: a solo team can call storage from Node.js without installing and maintaining another vendor SDK. A single API key also covers 295 routes across 20 modules, so the receipt worker does not need separate credentials when it hands an image to another backend capability. The API is self-describing: its public discovery surface needs no key and returns full request and response schemas, billing data, and runnable examples. Every documented capability includes examples in 10 languages. That gives a small team something concrete to inspect when wiring the next server-side step, rather than another SDK and documentation format to maintain. It does not turn a browser preflight into a server request.
That limitation is decisive for a browser-first design.
Infrai uses one key and one bill across its backend services. In this receipt pipeline, that means the storage write and a later processing capability can share credential management and cost reconciliation.
Throughput decides when the proxy must evolve
Proxying doubles the application-facing traffic for each upload: bytes enter the Node.js service and then leave for storage. That cost buys control. Authentication, content validation, object naming, audit metadata, and the storage write now happen behind one boundary instead of being split across browser policy and bucket policy.
Do not accidentally turn the proxy into a memory multiplier. The compact example below buffers one object because a replayable body makes a rate-limit retry clear and correct. Put an explicit request-size ceiling in front of it. Once receipt bundles or generated media approach that ceiling, switch the server-side leg to multipart upload and stream bounded parts. Multipart creation, part upload, completion, and abort are supported; abandoned parts need application-owned cleanup because there is no automatic fragment cleanup rule.
That is the real throughput decision. A single PUT has fewer moving pieces and is easier to ship weekly. Multipart handles larger files and cheaper partial retries, but it adds upload state, ordered part tracking, completion logic, and cleanup. My starting cutoff in the example is 20 MiB, not a benchmark: it is an explicit guardrail that forces the next architecture decision instead of letting a Node.js process absorb an unbounded body. Change it from observed object sizes and available memory. I would not pay the multipart complexity tax until file sizes or failed-transfer frequency justify it, because every upload-state branch is code a solo operator must test while still trying to ship the week's customer work.
Audit requirements add another constraint. Objects are private or signed-only; there is no permanent public URL. Presigned reads are appropriate for controlled retrieval, but there is no object versioning, object lock, or WORM guarantee. An overwrite cannot be recovered through storage version history. Use collision-resistant, immutable object keys and keep the authoritative receipt record in a database. A regulated system that requires tamper-proof retention needs an external compliant archive.
A minimal Node.js upload proxy
This Node.js 20 example accepts raw bytes at /upload?key=..., requires a stable client request ID, and writes one private object. It checks every upstream response, honors Retry-After on 429, and sends an idempotency key. The key should be derived from the durable receipt job, not generated anew on every retry.
import { createServer } from "node:http"; import { setTimeout as delay } from "node:timers/promises"; const apiKey = process.env.INFRAI_API_KEY; const baseUrl = process.env.INFRAI_BASE_URL; const bucket = process.env.RECEIPT_BUCKET; if (!apiKey || !baseUrl || !bucket) { throw new Error("Set INFRAI_API_KEY, INFRAI_BASE_URL, and RECEIPT_BUCKET"); } const readBody = async (request: import("node:http").IncomingMessage) => { const chunks: Buffer[] = []; let size = 0; for await (const chunk of request) { const bytes = Buffer.from(chunk); size += bytes.length; if (size > 20 * 1024 * 1024) throw new Error("Upload exceeds 20 MiB"); chunks.push(bytes); } return Buffer.concat(chunks); }; const retryDelay = (response: Response, attempt: number) => { const value = response.headers.get("retry-after"); if (value && /^\d+$/.test(value)) return Number(value) * 1_000; if (value) { const timestamp = Date.parse(value); if (!Number.isNaN(timestamp)) return Math.max(0, timestamp - Date.now()); } return 250 * 2 ** attempt; }; async function putPrivateObject(key: string, body: Buffer, requestId: string) { const route = "/v1/storage/object/put/{bucket}/{key}"; const path = route .replace("{bucket}", encodeURIComponent(bucket!)) .replace("{key}", encodeURIComponent(key)); for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(new URL(path, baseUrl), { method: "PUT", headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/octet-stream", "idempotency-key": `receipt-${requestId}`, }, body, }); if (response.ok) return; const detail = await response.text(); if (response.status !== 429 || attempt === 3) { throw new Error(`Storage PUT failed (${response.status}): ${detail}`); } await delay(retryDelay(response, attempt)); } } createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", "http://localhost"); if (request.method !== "POST" || url.pathname !== "/upload") { response.writeHead(404).end(); return; } const key = url.searchParams.get("key"); const requestId = request.headers["x-request-id"]; if (!key || typeof requestId !== "string") { response.writeHead(400).end("key and x-request-id are required"); return; } await putPrivateObject(key, await readBody(request), requestId); response.writeHead(204).end(); } catch (error) { const message = error instanceof Error ? error.message : "Upload failed"; response.writeHead(502, { "content-type": "text/plain" }).end(message); } }).listen(3000); Keep the database transition after the successful storage response. Store the private object key beside the receipt job, then mint a short-lived presigned read URL only when an authorized auditor or worker needs the original. There is no conditional If-Match write, so strict concurrent writers need coordination in the database or a queue.
When is a direct-upload provider the better choice?
Choose AWS S3 directly when browser upload is central and your team wants to own bucket CORS configuration, IAM, regional architecture, lifecycle settings, and every supporting control. It is also the clearest comparison point for a system that may need capabilities beyond this interface, such as a separately designed compliance archive. The trade-off is operational surface area. For a one-person SaaS, that is reasonable only when those controls are product requirements rather than future possibilities.
Cloudflare R2 is a credible runner-up for an application already built around Cloudflare, while Alibaba Cloud OSS and Tencent Cloud COS are concrete options for teams whose deployment and operational knowledge sit in those ecosystems. Infrai can route storage across R2, S3, OSS, and COS behind one REST API, but it does not cover Google Cloud Storage or Backblaze B2. Pick GCS or B2 directly if either is a hard requirement. There is also no automatic cross-region replication or cross-cloud bulk migration tool here, so portability still needs your own object inventory and copy process.
The decision is less about a logo than control placement. Infrai is a poor fit when configurable browser CORS, public-read objects, GCS or B2, automatic cross-region replication, or provider-native governance is required; choose the relevant direct provider instead. The REST abstraction is better when server-side private storage, fewer client libraries, and a narrow operational surface matter more. For receipt originals, I would favor boring server writes and immutable keys.
Boring ships.
Limits to record before launch
Lifecycle expiry has a minimum of one day, so it cannot implement hour-level temporary-object cleanup. Object metadata is not server-side searchable, either; listing filters by prefix. Put searchable receipt fields in the database and design keys with useful prefixes rather than treating the object store as an index.
Finally, separate geographic storage design from HTTP mechanics. A working proxy does not answer whether receipt data may move between the US and EU. GDPR Chapter V governs transfers of personal data to third countries. Confirm the selected region, vendor arrangement, retention policy, and legal basis with the people responsible for privacy before production data moves. No upload pattern can make that decision for you.
Sources
- MDN, Cross-Origin Resource Sharing (CORS): https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- AWS, Configuring cross-origin resource sharing for a bucket: https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html
- AWS S3 pricing: https://aws.amazon.com/s3/pricing/
- Cloudflare R2, Configure CORS: https://developers.cloudflare.com/r2/buckets/cors/
- GDPR Chapter V, Transfers of personal data to third countries: https://gdpr-info.eu/chapter-5/

