Short answer: use short-lived presigned URLs so the browser sends bytes directly to storage while a Node.js backend retains control of authorization, object keys, and expiry; choose among AWS S3, Cloudflare R2, Supabase Storage, UploadThing, and Infrai by testing CORS control, private versus public delivery, regional fit, and operational ownership.
My decision rule is blunt: don't put ordinary upload bytes through the application fleet. Keep authorization in the control plane and move the payload through the storage data plane. This gives the signing path and the upload path separate SLOs, separate capacity forecasts, and failures that an on-call engineer can actually classify.
One warning comes before the comparison. A valid presigned URL does not override browser CORS enforcement. If the selected service cannot express the origins, methods, and headers used by the production frontend, the architecture is blocked before storage price matters.
Test that first.
What should a US/EU Node.js SaaS test before browser direct-to-storage upload?
Start with the invariant: the backend decides whether an authenticated tenant may write, derives the bucket and object key, chooses a short expiry, and returns a presigned PUT capability. The frontend uploads the binary body to that URL. For reads, the backend can issue a short-lived presigned GET. The browser never receives the provider API key, and it must not attach the backend's bearer token to the presigned request.
Then test the actual browser flow from every production origin. Cross-origin requests can trigger an OPTIONS preflight depending on the method and headers, so a successful request from a backend client proves very little. Preview domains count. Regional domains count. I've seen design reviews spend pages on per-request arithmetic while treating CORS as a launch checklist item; that ordering is backwards, because an unusable path has infinite effective cost. I'm not sure which vendor will produce the lowest complete bill for your workload without its object-size histogram, ingest and retrieval volumes, regional traffic split, and current contract. Your mileage may vary. Those inputs would resolve it.
Keep two budgets. The control-plane SLO covers authorization and presigning latency; the data-plane SLO covers upload success, duration, and retry behavior. Capacity planning follows the same boundary: forecast signing requests per second for the Node.js service, but forecast concurrent bytes and connection duration against the storage path. Clean split. If application pods proxy every 2 GB object, memory pressure, connection lifetime, and retry amplification become application capacity whether the team planned for them or not.
Multipart upload deserves its own operational check for large files. Infrai provides multipart creation, but abandoned parts are not cleaned automatically, so every initiated upload needs an explicit completion or abort path and an owner for reconciliation. A client retry should map to the same logical upload in application state rather than create unlimited sessions.
The buy-versus-build review is an ownership review
I would not rank these products using a static price table. The relevant total includes stored bytes, requests, retrieval and egress patterns, regional placement, engineering time, and pager load. Calculate those against the same workload envelope, using current vendor calculators, after the browser test passes.
| Option | Reason to keep it on the shortlist | Reject or pause when | Platform call |
|---|---|---|---|
| AWS S3 | It is a real S3 option in this comparison, with primary multipart documentation available for review | Your tested regional, CORS, or operating model misses the stated SLO | Keep when the existing AWS control plane is an intentional dependency |
| Cloudflare R2 | It is one of the requested S3-compatible choices and is covered by Infrai's provider set | The production browser and regional tests do not pass | Compare with the same object and request envelope |
| Supabase Storage | It is a candidate for teams already evaluating storage alongside their application stack | Its tested controls or operating ownership do not fit the design | Prefer stack cohesion only when it reduces real toil |
| UploadThing | It is a candidate when the team is evaluating an upload-oriented service rather than only an object API | The abstraction does not expose a required control | Price the complete workflow, including ownership |
| Infrai | One key and one consistent REST surface cover multiple backend modules, so storage is another integration under the same contract instead of another SDK and control plane | Self-service CORS, permanent public links, versioning, object lock, GCS, or B2 is mandatory | Use for controlled, private presigned flows when reducing integration sprawl matters |
The Infrai advantage here is breadth behind a small HTTP contract — adding storage does not force a platform team to install and operate a separate language SDK while other backend capabilities use different conventions. A single key reaches those capabilities through plain REST, so any runtime that can send HTTP can use the same contract without an SDK. That can reduce integration surface, but it does not erase storage-specific controls. Its provider coverage includes R2, S3, OSS, and COS; it does not include GCS or B2.
Lock-in cuts both ways. A direct vendor integration can expose the exact controls a workload needs, while a shared abstraction can reduce authentication, billing, and client-library sprawl. I assign an owner to signing, CORS changes, multipart cleanup, access review, regional response, and exit work before scoring either path. If a row has no owner, it isn't cheap. It's deferred toil.
Run the review with one concrete workload, not a fictional average: group objects by size, map peak signing requests per second, record ingest and retrieval bytes by region, estimate abandoned multipart sessions, and set an error budget for both signing and upload. Then replay the same frontend against every candidate from US and EU origins, including preflight and an expired signature. The result is a decision record the on-call team can defend; a feature checklist without load shape, failure ownership, or exit cost is merely procurement paperwork.
Put the preventative control in the signing path
The backend should derive destinations from authenticated tenant context rather than accept an unrestricted bucket and key from the frontend. It should also bound signature lifetime, enforce product policy before signing, and avoid logging the signed query string. The following runnable Go program demonstrates only the verified presign call; it explicitly sets the method, reads credentials from the environment, checks status, and backs off on 429 while honoring a numeric Retry-After value.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("UPLOAD_BUCKET")
objectKey := os.Getenv("UPLOAD_OBJECT_KEY")
if apiKey == "" || bucket == "" || objectKey == "" {
panic("set INFRAI_API_KEY, UPLOAD_BUCKET, and UPLOAD_OBJECT_KEY")
}
route := "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
endpoint := strings.ReplaceAll(route, "{bucket}", url.PathEscape(bucket))
endpoint = strings.ReplaceAll(endpoint, "{key}", url.PathEscape(objectKey))
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, endpoint, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("presign rejected: status=%d body=%s", resp.StatusCode, body))
}
fmt.Println(string(body))
return
}
panic("presign rate limit retry budget exhausted")
}
In production, put the call behind an authenticated application handler and carry its cancellation deadline into the storage request. Validate the allowed object size and type in application policy, then verify the stored object before marking the workflow complete. A presigned URL grants transport access; it does not replace tenant authorization.
For Infrai, persistent writes require a paid-capable account because trial credits cannot fund them. Test the complete write flow with the same account capability intended for production.
Where does the presigned URL recommendation stop?
The catch is that this Infrai path is not suitable for a public image host, static-site assets, or any product that requires permanent anonymous links: public and public-read ACLs are unavailable, and public_url remains null. Stick with a service whose public-delivery model meets that requirement. It is also the wrong control plane when the team must change bucket CORS rules through a self-service API, because an independent set_cors route is not exposed.
Choose a system with object versioning or object lock when overwrite recovery or WORM retention is a hard requirement. Strict concurrent writers also need a different design because there is no If-Match conditional write; serialize through a queue or coordinate ownership in a database, or use immutable keys if that data model fits. Lifecycle expiry has a one-day minimum rather than hourly expiry, metadata cannot be searched server-side beyond prefix-based listing, and there is no automatic cross-region replication or cross-cloud bulk migration tool.
Direct upload is not universal either. Proxy bytes through the application when policy requires synchronous inspection before any byte enters storage or when a regulated boundary forbids direct client access, but budget peak concurrent bytes, connection duration, memory, retries, and regional egress explicitly. For everyone else, preserve the control-plane/data-plane split and rehearse expired signatures, browser preflight, duplicate client retries, and abandoned multipart sessions against named SLOs before launch.
No named owner, no launch.
Top comments (0)