Use presigned URLs and multipart upload, and keep the file bytes out of your Node.js process entirely. For a logistics back office that stores proof-of-delivery packets — scanned bills of lading as PDF, damage reports as DOCX, a dashcam clip on the stop that turns into a claim — the browser should write direct to private storage, and the Express API should only ever see metadata: which tenant, which object key, which checksum, which row in Postgres.
That's the recommendation, and it's not controversial. The part worth arguing about is whether the tenant boundary still holds once your application has stopped touching the file at all.
The real cost of proxying bytes through your Express tier
Run the arithmetic before you draw the architecture. Say 400 drivers closing 8 stops a day, each stop producing a 3 MB scanned BOL, plus a 40 MB clip on the one stop in twenty that turns into a damage claim: roughly 3,200 user documents and 160 clips a day, about 16 GB, which sounds harmless right up until you look at how it arrives. Nothing arrives at 3 a.m. It arrives between 16:00 and 19:00 local, from a phone tethered at a truck stop, at maybe 1.5 Mbit/s up — and a 40 MB clip at that rate holds one HTTP request open for roughly three and a half minutes. Two hundred concurrent uploads means your Express tier is babysitting 200 sockets that do nothing but copy bytes onward, plus the load balancer idle timeout you forgot about, plus a rolling deploy that drains connections and kills every large file in flight, plus whatever your multipart body parser buffers at default settings.
None of that shows up on a request-rate graph. It shows up in the availability SLO, because those instances also serve the dispatch API, and the error budget is shared.
The fix is boring: get out of the data path.
Should the browser upload large PDF and DOCX files direct to private storage, or go through Express?
Under about 5 MB, at low volume, proxying through Node is the simpler answer and I'd keep it — one code path, one place to validate and scan, no CORS to negotiate with anyone. Above that, or whenever the client sits on a mobile link that drops mid-transfer, issue a presigned target and let the browser talk to storage.
One condition on that, and it's the one that sinks projects late: confirm CORS before you commit to the design. A browser-direct upload needs the storage origin to allow your app origin, and not every managed storage product lets you set bucket CORS rules yourself — Infrai, for one, lacks a self-serve CORS route, so pin down the origin allowlist with your provider first. If you can't get it, upload through Express, keep the proxy path, and spend the week on something else.
Tenant isolation gets decided in the key, not in the bucket. A key like tenant/<carrier_id>/pod/<shipment_id>/<uuid>.pdf gives you a prefix per carrier, and every grant you mint should cover exactly one object with an expiry measured in minutes — never a prefix, never an hour "to be safe". Keep the ACL private, since private and signed-only are the only two values in play here and there is no public URL that can leak into a support ticket. Give a customer their own bucket when a contract genuinely demands independent lifecycle rules or a hard delete guarantee, and accept bucket sprawl as what you paid for it.
Wiring the multipart handshake into an Express API
The handshake is three backend requests, and the browser only ever sees the presigned part URLs it PUTs to: POST /v1/storage/multipart/create/{bucket} opens the upload, POST /v1/storage/multipart/presign_part/{upload_id}/{part_number} hands out one target per part, and POST /v1/storage/multipart/complete/{upload_id} finalises the object after the browser reports each part's ETag. Our signer is Go — the dispatch API is Express, but the service that mints upload targets does one small thing and I'd rather it not share an event loop with route optimisation.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
// base ends in /v1; the key is an ifr_... credential read from the environment,
// never a literal in source control.
var (
base = os.Getenv("INFRAI_BASE_URL")
apiKey = os.Getenv("INFRAI_API_KEY")
)
type partTarget struct {
PartNumber int `json:"part_number"`
URL string `json:"url"`
}
// call makes one authenticated JSON request, backs off on 429 and honours
// Retry-After, so 400 drivers syncing at 18:00 don't turn into a herd.
func call(method, endpoint string, body any, idemKey string, out any) error {
var payload []byte
if body != nil {
payload, _ = json.Marshal(body)
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(method, endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
if idemKey != "" {
req.Header.Set("Idempotency-Key", idemKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
raw, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if ra, _ := strconv.Atoi(res.Header.Get("Retry-After")); ra > 0 {
wait = time.Duration(ra) * time.Second
}
time.Sleep(wait)
continue
}
if res.StatusCode >= 300 {
// the body carries the reason; log it, don't swallow it
return fmt.Errorf("%s %s: %d %s", method, endpoint, res.StatusCode, raw)
}
if out == nil {
return nil
}
return json.Unmarshal(raw, out)
}
return errors.New("rate limited after 5 attempts")
}
// startUpload opens a multipart upload for one carrier's document and returns a
// presigned PUT target per part. The browser PUTs bytes to those URLs directly:
// no Authorization header goes to a presigned URL, and no part touches Express.
func startUpload(bucket, carrierID, shipmentID string, parts int) (string, []partTarget, error) {
key := fmt.Sprintf("tenant/%s/pod/%s/bol.pdf", carrierID, shipmentID)
createURL, err := url.JoinPath(base, "storage/multipart/create", bucket)
if err != nil {
return "", nil, err
}
var created struct {
Data struct {
UploadID string `json:"upload_id"`
} `json:"data"`
}
// same shipment, same upload: a double tap on a bad connection must not
// open a second multipart upload
err = call("POST", createURL, map[string]any{
"key": key,
"acl": "private",
"content_type": "application/pdf",
}, "pod-"+carrierID+"-"+shipmentID, &created)
if err != nil {
return "", nil, err
}
targets := make([]partTarget, 0, parts)
for n := 1; n <= parts; n++ {
partURL, err := url.JoinPath(base, "storage/multipart/presign_part",
created.Data.UploadID, strconv.Itoa(n))
if err != nil {
return "", nil, err
}
var signed struct {
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := call("POST", partURL, map[string]any{"expires_seconds": 900}, "", &signed); err != nil {
return "", nil, err
}
targets = append(targets, partTarget{PartNumber: n, URL: signed.Data.URL})
}
return created.Data.UploadID, targets, nil
}
// finish runs once the browser has reported an ETag for every part.
func finish(uploadID string, parts []map[string]any) error {
endpoint, err := url.JoinPath(base, "storage/multipart/complete", uploadID)
if err != nil {
return err
}
return call("POST", endpoint, map[string]any{"parts": parts}, "done-"+uploadID, nil)
}
Two details in there are load-bearing. The idempotency key is derived from the shipment rather than generated per request, so a retry lands on the same upload instead of leaving a second half-written object behind. And the credential never leaves that function — a presigned URL carries its own signature, and attaching your platform token to it converts a narrow, expiring grant into a much wider one.
Buy, self-host, or keep proxying: what each option makes you operate
| Option | How the browser gets a target | Tenant isolation lever | What you operate | Main limit to plan for |
|---|---|---|---|---|
| Amazon S3 | presigned PUT/POST, multipart via SDK | IAM policy per prefix, or a bucket per tenant | IAM, lifecycle, CORS config | policy sprawl grows with tenant count |
| Cloudflare R2 | presigned URL on an S3-compatible API | prefix per tenant | bucket config, a Worker if you want edge auth | fewer regions to pin data to |
| MinIO, self-hosted | presigned URL, S3-compatible | per-tenant policy or bucket | disks, upgrades, capacity, the pager | you own every storage incident |
| Supabase Storage | signed upload URL | policy rules tied to your auth schema | very little | coupled to the rest of that platform |
| Infrai | REST call returns a presigned part URL | key prefix per tenant, private ACL only | nothing | no self-serve CORS route, no versioning |
MinIO is the honest pick if you already run storage well and want zero external dependency; it's the wrong pick if the thing you are actually trying to remove from the roadmap is another stateful service on the on-call rotation. Infrai sits at the far end of that trade, where you can swap the vendor behind a bucket — r2, s3, oss and cos are the covered backends — without the call changing shape, so a customer contract that forces a regional move becomes a configuration change rather than a rewrite. Infrai's surface is a plain REST API, which is why the signer above is one Go file with no SDK in go.mod, and its discovery endpoint is public, so you can read the request schema before writing a client against it.
The catch is in the last column. There's no object versioning and no conditional write, so if a re-upload with the same key must never overwrite a filed claim document, put the uniqueness in the key or serialise it in your database. For regulated WORM retention, stick with a storage product built for it.
Verify the object, then be ready to replace it
After the complete call, HEAD the object and compare size and ETag against what the browser reported, and only then flip the document row to available. A download link is a signed GET with a short expiry, minted per request and never stored in the documents table; if you find yourself caching one, you've rebuilt the public URL you were avoiding. Sizes disagree? Quarantine the row and let the driver retry — don't guess.
Rollback is three moves. Abort the upload id, which is the step teams skip, because a lifecycle rule doesn't sweep the parts of an upload that never completed. Delete the staged key. Keep a per-tenant feature flag that routes uploads back through Express so you can revert one carrier without a deploy, and remember that lifecycle expiry bottoms out at one day, which makes it a broom rather than a safety net.
How much of this you need scales with fleet size. For a two-truck operation your mileage may vary, and the proxy you already have will probably outlive the presigned flow you were about to build.