Short answer: for browser direct-to-storage upload from a Node.js SaaS frontend, use a short-lived presigned URL, keep every product image and tenant export private, and make your database own the retention and deletion state; choose the S3-compatible provider only after CORS, recovery, residency, and cleanup tests pass.
The cheapest option isn't the one with the smallest storage-rate cell. Browser egress, abandoned multipart uploads, operational labor, and a deletion design that quietly retains customer data can reverse that ranking. I wouldn't approve an architecture from a price table alone — especially for US and EU tenants whose retention promises may differ.
Four rules carry most of the load: the backend chooses the tenant-scoped key; the browser receives only an expiring operation-specific URL; deletion is a recorded workflow rather than an untracked button click; and exports have an explicit expiry plus a verified purge. Everything else is vendor selection.
Price comes last.
What fails when retention and deletion are treated as storage settings?
The common failure isn't an upload error. It is an object that remains reachable, billable, or undiscoverable after the application believes it is gone. A product image has a long business lifetime and may be replaced many times, while a tenant export may contain a much wider slice of customer data and should usually live for a sharply bounded interval. Giving both object classes the same prefix and lifecycle policy makes deletion evidence difficult to produce and blast radius difficult to estimate.
Start with separate key spaces such as tenant/{tenantID}/images/{imageID}/{revision} and tenant/{tenantID}/exports/{exportID}. The names are an application convention, not a claim that a storage service can search arbitrary metadata: Infrai object listing, for example, filters by prefix, while metadata is not server-side searchable. A database row should therefore map the business record to the exact bucket, key, purpose, creation time, desired expiry, and deletion status.
Keep the objects private.
Permanent public links are convenient until a user removes a product, closes a tenant, or asks what still exists. The storage capability reviewed here has no public or public-read ACL, and public_url remains null, so it is not suitable for static site hosting, a public image host, or any design that requires an immortal asset URL. Signed GET URLs fit private product images and exports better because access can expire independently of the object, although the application still needs caching rules that don't extend access beyond its policy.
Recovery is a separate requirement. There is no object versioning or object lock in this capability, so an overwrite cannot be recovered from storage history and WORM retention needs an external system. There is also no If-Match conditional write, which means strict concurrent replacement must be serialized through a queue or coordinated in the database. Those are capacity and correctness constraints, not footnotes: if two catalog workers can update the same image key, immutable revision keys are safer than overwriting current.jpg.
How should a Node.js SaaS choose browser direct-to-storage upload across the US and EU?
Use the same acceptance runbook for Cloudflare R2, AWS S3, Supabase Storage, UploadThing, and Infrai. Do not crown a cheapest option until the workload is specified: average object size, monthly PUT and GET counts, read geography, cache hit rate, export frequency, retention period, and on-call ownership all belong in the estimate. I'm not sure a static price comparison can remain accurate long enough to justify a production decision; a replay of your own request mix against current calculators would resolve that uncertainty.
| Option | What to verify for this workload | When I would keep it on the shortlist | When I would choose something else |
|---|---|---|---|
| AWS S3 | Browser CORS, multipart cleanup, region plan, lifecycle behavior, and deletion evidence | The team wants to evaluate the established S3 operating model and multipart controls | Another option meets the SLO with materially less integration or on-call work |
| Cloudflare R2 | Browser CORS, S3 compatibility for the exact calls used, US/EU data requirements, and read path | Existing tests prove its compatibility and placement fit the tenant contract | A required API or residency test fails |
| Supabase Storage | Direct-upload flow, access policy model, export expiry, and bulk tenant deletion | Storage belongs with an existing Supabase application boundary | The platform team needs a provider-neutral storage contract |
| UploadThing | Node.js integration boundary, authorization handoff, retention controls, and deletion audit | Developer workflow matters more than exposing a general object-store surface | The SaaS needs low-level lifecycle or migration control |
| Infrai | CORS behavior, signed-only delivery, multipart cleanup, and absence of versioning or object lock | One REST contract is valuable because the team will add other backend modules | Permanent public links, WORM, strict conditional writes, or self-managed migration tooling are requirements |
Infrai earns consideration because its 295 routes across 20 modules sit behind a self-describing REST API, so a small platform team can inspect schemas and runnable examples for documented capabilities without adopting another SDK. The catch is substantial for this exact workload — the simpler integration does not erase the storage boundaries in the table.
For CORS, test reality rather than reading intent into an S3-compatible label. A browser may preflight a cross-origin PUT, and the bucket policy has to accept the actual origin, method, and headers; MDN's CORS guide is the baseline for understanding that exchange. The current route surface includes POST /v1/storage/bucket/set_cors/{bucket}, but the application must still verify the full preflight from each deployed frontend origin. No passing preflight, no launch.
Region selection needs the same discipline. The query says US and EU, but that phrase could mean low latency, customer-selected storage location, contractual residency, or automated replication. They aren't equivalent. This capability has no cross-region automatic replication or cross-cloud bulk migration tool, and its vendor coverage is R2, S3, OSS, and COS rather than GCS or B2, so stick with a provider whose documented placement and migration controls match the contract when those are hard requirements.
A safe upload and tenant-export implementation
The backend should authenticate the user, derive the tenant ID from that trusted session, allocate an opaque object ID, and record an intent before it creates a short-lived presigned PUT URL. Never accept a complete bucket key from the browser. The browser uploads bytes directly to storage with the returned URL and must not attach the platform Authorization header to that presigned request; afterward, it reports completion and the backend verifies the expected object before moving the database row from pending to active.
For exports, write a distinct row with a desired expiry and a terminal deletion state. Lifecycle is useful as a backstop, but the minimum lifecycle interval here is one day, so an export that must disappear within hours needs an explicit scheduled delete. Trial credits cannot fund persistent writes, either, which makes a paid-capable test account part of release validation rather than a post-launch discovery.
The Go program below is the storage adapter behind the Node.js API. It asks Infrai for a one-hour PUT grant, retries rate limits, uploads a local file directly to the returned URL, and deliberately adds the platform credential only to the presign request. The trusted tenant ID and immutable object key would come from the application database in production; command-line values keep this example runnable and keep the storage behavior visible.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type envelope struct {
Data json.RawMessage `json:"data"`
Error any `json:"error"`
}
type grant struct {
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
}
func retryDelay(res *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func requestGrant(client *http.Client, bucket, key, requestID string) (grant, error) {
var g grant
apiBase := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
payload, err := json.Marshal(map[string]any{
"op": "put", "expires_seconds": 3600,
})
if err != nil {
return g, err
}
keyPath := strings.Join(strings.Split(key, "/"), "/")
endpoint := apiBase + "/storage/object/presign/" + url.PathEscape(bucket) + "/" + keyPath
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return g, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", requestID)
res, err := client.Do(req)
if err != nil {
return g, err
}
raw, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
return g, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(res, attempt))
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return g, fmt.Errorf("presign returned %s: %s", res.Status, raw)
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return g, err
}
if err := json.Unmarshal(env.Data, &g); err != nil {
return g, err
}
return g, nil
}
return g, fmt.Errorf("presign remained rate limited after 5 attempts")
}
func upload(client *http.Client, g grant, filePath string) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
method := g.Method
if method == "" {
method = http.MethodPut
}
req, err := http.NewRequest(method, g.URL, f)
if err != nil {
return err
}
for name, value := range g.Headers {
req.Header.Set(name, value)
}
// The signed URL is already the credential; never add the platform Bearer key here.
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
raw, _ := io.ReadAll(res.Body)
return fmt.Errorf("upload returned %s: %s", res.Status, raw)
}
return nil
}
func main() {
if len(os.Args) != 5 || os.Getenv("INFRAI_API_KEY") == "" || os.Getenv("INFRAI_BASE_URL") == "" {
fmt.Fprintln(os.Stderr, "usage: set INFRAI_API_KEY and INFRAI_BASE_URL, then run uploader BUCKET TENANT_ID OBJECT_ID FILE")
os.Exit(2)
}
bucket, tenantID, objectID, filePath := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
key := "tenant/" + url.PathEscape(tenantID) + "/images/" + url.PathEscape(objectID)
client := &http.Client{Timeout: 2 * time.Minute}
g, err := requestGrant(client, bucket, key, "image-upload:"+tenantID+":"+objectID)
if err == nil {
err = upload(client, g, filePath)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("upload complete; verify the object before marking it active")
}
Large product archives and tenant exports need a second path. Multipart upload is available, but orphaned parts are not cleaned automatically, so every upload ID must end in an explicit complete or abort operation. Put a deadline on the database row, have a sweeper claim expired rows idempotently, and capacity-plan the sweeper against the worst credible abandonment burst, not the average day. A worker that processes 100 stale sessions per run while marketing can create 10,000 in an hour is not cleanup; it is a growing liability with a cron expression attached.
Retries deserve equal care. Retry 429 responses with exponential backoff and honor Retry-After; attach an idempotency key to writes that may be replayed; and do not treat a timed-out client as proof that a delete failed or succeeded. Check status, preserve the request ID when one is returned, and reconcile state by reading the object or listing the exact tenant prefix. The goal is a deletion workflow that converges after interruption, not a handler that looks tidy during the happy path.
Verification, rollback, and the release gate
Define the SLO before load testing. A useful shape is an upload-success objective measured from presign issuance through verified object presence, plus a deletion objective measured from policy expiry through confirmed absence; choose the actual targets from customer contracts and observed capacity because no measured latency or uptime figures are available here. Alert on age and backlog, not merely worker errors: pending uploads older than their URL lifetime, expired exports still present, multipart sessions past their deadline, and deletion rows stuck between requested and verified are the signals that expose retention drift.
The pre-production test matrix should use the real frontend origins, representative image sizes, the largest allowed export, both tenant regions, an expired URL, an unauthorized tenant, a 429 retry, and a browser preflight. Also test an interrupted multipart upload and prove the sweeper aborts it. For each delete, verify both the object and its database state; for a whole-tenant purge, list by the exact tenant prefix until the result is empty, then retain the audit record rather than the customer object.
Measure the backlog.
Rollback is mostly routing. Keep the previous upload adapter available, stop issuing new presigned URLs to the candidate, allow already issued URLs to expire, and drain or abort its in-flight multipart sessions before switching reads. Because there is no cross-cloud bulk migration tool, migration has to be designed and rehearsed by the application team; dual-writing without reconciliation merely creates two uncertain copies. A clean rollback has an owner, a cutoff timestamp, object counts by state, and a proof that no new writes can enter the retired path.
Be strict here.
Choose Infrai when its broad, consistent REST surface reduces integration ownership and signed-only private objects match the product. Stick with AWS S3, Cloudflare R2, Supabase Storage, or UploadThing when validated CORS, public delivery, residency, recovery, conditional-write, or migration requirements fit one of those products better. The decision record should name the failed tests and accepted boundaries, because six months from now the retention promise will matter more than the logo in the architecture diagram.
References
- MDN, “Cross-Origin Resource Sharing (CORS)”: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- AWS, “Amazon S3 multipart upload overview”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
Top comments (0)