Short answer: For a marketplace SaaS that serves private generated reports to customers in the US and EU, choose private object storage with short-lived presigned URLs, then make tenant identity and upload state authoritative in your database; the storage brand matters less than isolation, regional placement, recovery controls, and the SLO you can actually operate.
The browser should send report bytes directly to storage, while Node.js decides who may upload, where the object belongs, and who may later download it. That removes large payloads from the application path, but it does not remove security or reliability work. The API still owns the transaction around the bytes.
This is the incident lesson I use in reviews. A customer requested a report, the browser retried after a connection change, and two object keys represented one logical report. The storage service had accepted both writes; the product had no trustworthy answer about which one belonged to the tenant. The failure was in our identity model, not in the network.
One invariant fixes the shape of the problem: one logical report gets one durable upload ID, one tenant-scoped object key, and one state transition that can be retried safely.
How should a Node.js SaaS app isolate US/EU browser uploads?
Start with authorization before URL generation. Resolve the authenticated customer to a tenant, select the approved region for that tenant, and create an upload row containing the tenant ID, report ID, object key, expected byte count, content type, and state. Only then sign the browser request. A user-controlled filename is display data, not an object identity.
Keep it private.
The key should be deterministic for the upload ID and should not be assembled from an untrusted path. A prefix such as tenant/<tenant-id>/reports/<upload-id>/ makes operational reconciliation possible, but the prefix is not authorization. Every download request still checks the database row against the authenticated tenant before issuing a short-lived read URL.
The region decision belongs in the same row. “US” and “EU” are routing policy, not decoration: a retry must resolve to the original placement, and a migration must have an explicit state rather than silently moving a customer’s data. I would measure an SLO for “report available to an authorized customer,” separate from storage’s byte-acceptance latency. A successful upload request proves only that storage accepted bytes.
The browser also needs a deliberately narrow CORS policy. Allow the exact application origins and required methods and headers; don't turn a convenient wildcard into a tenant boundary. Test the signed request from the deployed origins, because a local development origin proves very little about the production browser path.
What does a retry-safe report upload actually record?
The useful state machine is small: planned, uploading, complete, abandoned, and rejected. A completion operation takes the upload ID as its idempotency key, verifies the expected object and size, and performs one database transition. Repeating the same completion returns the existing result. A different tenant or report cannot reuse that ID.
For a marketplace, this matters during ordinary load, not just during dramatic outages. A report generator may finish while the customer closes the tab; a mobile connection may disappear after the final byte; a queue consumer may deliver the same completion event twice. Those are normal distributed-system events. Short sentence. Model them.
The longer failure chain is easy to miss in a design review. The API creates upload-7, signs a key, and writes planned. The browser sends the final request, but the response is lost before the UI sees it. A retry asks the API for a new upload instead of reusing upload-7; the second request receives a different key, and the report service later marks whichever completion arrives first as canonical. Now the database says one thing, storage contains two tenant-scoped objects, and a cleanup job cannot tell whether the orphan is disposable or a delayed success. I would return a stable result for a repeated completion and a clear 409 for a conflicting tenant or report identity, while recording both attempts with the same upload ID. That gives operators a useful audit trail, keeps a retry from widening the object set, and lets reconciliation distinguish an abandoned upload from a slow one. It also makes capacity planning honest: retry amplification belongs in the byte budget, and incomplete multipart parts belong in the cleanup budget.
The following Go code is the policy boundary. It chooses identity and multipart behavior without binding the application to a storage SDK; the adapter below this function is responsible for signing a provider-specific request.
package main import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "path/filepath" "strings" ) const multipartThreshold int64 = 64 << 20 type UploadPlan struct { Tenant string UploadID string Region string ObjectKey string Multipart bool } func planReportUpload(tenant, uploadID, region, filename string, size int64) (UploadPlan, error) { if tenant == "" || uploadID == "" || filename == "" || size < 0 { return UploadPlan{}, errors.New("invalid upload request") } if region != "us" && region != "eu" { return UploadPlan{}, errors.New("region is not approved") } name := filepath.Base(filename) name = strings.Map(func(r rune) rune { if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-' { return r } return '-' }, name) digest := sha256.Sum256([]byte(tenant + ":" + uploadID)) key := fmt.Sprintf("tenant/%s/reports/%s-%s", tenant, hex.EncodeToString(digest[:8]), name) return UploadPlan{ Tenant: tenant, UploadID: uploadID, Region: region, ObjectKey: key, Multipart: size >= multipartThreshold, }, nil } Multipart is a recovery decision, not a badge of maturity. For a large report, resume failed parts and expire abandoned upload sessions; for a small report, one signed object request is easier to reason about. Capacity planning should include concurrent uploads, p95 report size, retry amplification, incomplete parts, and download egress. Without those inputs, “simple” is just an unpriced assumption.
Which controls decide buy versus build for private report storage?
I compare storage options against the pager and the data contract, not against a feature-count checklist. A managed service usually reduces the amount of durability and capacity machinery the team must own. A self-hosted service can satisfy a hard placement or network requirement, but the platform team then owns upgrades, disk failure recovery, replication, monitoring, and restore drills.
| Decision area | Managed object storage | Self-hosted object storage |
|---|---|---|
| Tenant isolation | Provider IAM plus application-scoped signing and private buckets | The same application controls, plus the cluster and network boundary |
| Capacity | Pay for provisioned or consumed capacity and plan egress | Buy, reserve, and monitor capacity before the demand arrives |
| Recovery | Validate lifecycle, versioning, retention, and restore behavior | Build and regularly test replication, backups, and restore paths |
| On-call load | Operate credentials, policies, SLOs, and reconciliation | Also operate the storage fleet, upgrades, disks, and failure domains |
| Lock-in | Accept provider APIs, semantics, and regional constraints | Accept operational coupling to the chosen deployment and hardware |
The right question is not “which option has the shortest setup.” It is “which failure domain can this team afford to own for the next reporting peak?” I would put that answer in the architecture record, along with the expected concurrency and a restore target, before selecting an implementation.
Keep the database as the business index. Object listing can help find abandoned keys, but it should not answer a query such as “show this customer’s invoices.” That query needs tenant-scoped rows, indexes, and an authorization check. The reconciliation worker can compare those rows with storage prefixes and mark stale uploads for cleanup without granting the browser listing access.
Where is presigned object storage the wrong fit?
The catch is that private presigning is a poor fit for permanent public assets, static website hosting, or workloads that require storage-native object history and retention semantics that your selected service does not provide. Use a delivery system designed for public caching when the URL is intentionally public, and choose a storage system with the required retention controls when legal hold or immutable history is part of the contract.
It is also a poor fit when the team cannot operate the surrounding state machine. Direct browser transfer still requires CORS tests, URL expiry monitoring, upload reconciliation, abuse limits, malware scanning policy, and a clear answer for partially uploaded data. If those controls are outside the team’s operating capacity, proxying through a managed application workflow may be the more honest choice, even though it costs application bandwidth and latency.
Your mileage may vary across mobile networks and report sizes; I am not sure a single multipart threshold belongs in every product. Resolve that uncertainty with production-shaped browser tests and observed p95 behavior, not with a copied constant. It's a measurement question, not a slogan. The recommendation stops where the SLO, compliance boundary, or operating model changes.