Use private object storage with application-issued, short-lived signed URLs for a Node.js SaaS avatar flow, but keep the application out of the large-file data path. The practical decision is not which bucket has the nicest feature list; it is whether browsers can upload directly, whether authorization remains in your service, and whether the resulting throughput fits an explicit SLO.
Private by default.
The media scenario changes the sizing question. A user avatar may be small, while the same product may accept profile videos or other media files that are large enough to make proxying every byte through the API an avoidable capacity risk. I would use one control-plane contract for both: the application authenticates the user, allocates an opaque object key, and returns a narrowly scoped upload URL; the client sends bytes to storage; the application records the object only after verification.
Why does direct upload matter for large-file throughput?
Proxying a 2 GB media file through the Node.js process consumes ingress bandwidth, egress bandwidth, connection slots, memory buffers, and usually another copy in a load balancer. Even with streaming, the API remains on the critical path for every byte. That makes a storage decision look like an application scaling problem, and it makes a harmless retry much more expensive than its status code suggests.
Direct upload moves the data path from browser to object storage. The API still owns the control path: identity, policy, key allocation, quota checks, and the final database transition. A signed URL is a temporary capability, not a replacement for authorization. Check the caller before minting it, bind the key to an application-owned record, and never let a client choose another tenant's prefix merely by editing JSON.
Capacity planning should start with concurrency, not the nominal file size. Estimate active uploads, peak bytes per second, URL-minting requests per second, and the storage service's documented limits. Define separate SLOs for authorization and signing latency, upload completion rate, and final object availability. A fast signed-URL endpoint cannot compensate for a saturated client connection or an unbounded retry loop.
Bytes are not free.
Consider the failure sequence I would put in a design review. A browser asks for an upload URL, receives one for avatars/tenant-7/object-abc/original, and begins a multipart transfer. The user closes the tab after part 8 of 12, so there is no database pointer yet, but the storage service still has the incomplete upload. A client retry then asks the API to create another upload instead of resuming the first one; two control records now describe related work, and a cleanup job that only scans completed objects cannot see either one. The safer contract records the upload ID and intended key before returning instructions, treats completion as an idempotent operation keyed by that record, and gives the worker enough information to abort abandoned work. On completion, the service checks that the object belongs to the tenant, that its observed size is within policy, and that the database update still refers to the same pending upload. If the completion callback arrives twice, the second call returns the already-known result rather than replacing the user's current avatar. If a replacement finishes while an older request is still in flight, immutable keys make the race visible in the database instead of silently overwriting bytes. This is the kind of detail that changes throughput planning: the data path may be direct, but the state machine still consumes API requests, database writes, queue capacity, and operator attention. A dashboard that reports only gigabytes transferred will not show the abandoned multipart state or a client that is repeatedly creating uploads.
For a browser flow, CORS is part of the design contract. The allowed origin, methods, and request headers must match the actual upload request, and the preflight response must be tested from the deployed origin. If the storage service cannot expose the CORS controls your browser flow requires, keep the upload behind a service that can, or choose a storage arrangement with that capability. I'm not sure direct browser upload is worth the extra surface for a 40 KB avatar; it becomes much easier to justify when the same workflow handles large media.
How should a Node.js SaaS handle private avatar uploads and presigned download URLs?
Treat an upload as a state transition rather than as a successful HTTP request. Create an upload record with pending status and an opaque key such as avatars/<tenant-id>/<random-id>/original. Return a signed PUT or multipart-upload instruction with a short expiry. After the client reports completion, inspect the object metadata and apply product policy for size and content type before changing the user record to point at that key.
For small avatars, a single PUT is simpler and has fewer cleanup states. For a large media object, multipart upload reduces the cost of restarting after a failed transfer, but every part and incomplete upload becomes operational state. Set a bounded part size, cap parallelism on the client, and provide an explicit abort path. The S3 multipart overview describes the basic create, upload-parts, and complete sequence; the important operational point is that an incomplete upload still needs lifecycle governance.
Downloads follow the reverse boundary. The application checks the viewer's relationship to the user or tenant, then issues a signed GET URL for the current object key. The browser fetches that URL without the application's bearer token. Keep the URL out of ordinary logs where possible, because possession of an unexpired signed URL is temporary access to the object.
Keep the key opaque.
Use immutable keys for replacements. A fixed key such as avatars/user-42 creates a race between overlapping uploads and makes rollback depend on whether an old object was overwritten. Write the new object first, update the database pointer second, and retain the previous pointer until the retention policy permits deletion. Small images can have separately named thumbnail objects so resizing and cache invalidation do not mutate the original.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
type signRequest struct {
Operation string `json:"operation"`
ExpirySec int `json:"expiry_seconds"`
}
type signResponse struct {
URL string `json:"url"`
}
// The API owns authorization. The returned URL is used only for the object path
// selected by the server, never for a client-supplied tenant or user prefix.
func requestUploadURL(client *http.Client, apiURL, token, key string) (string, error) {
payload, err := json.Marshal(signRequest{Operation: "put", ExpirySec: 300})
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Object-Key", key)
res, err := client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return "", err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return "", fmt.Errorf("signing request returned %s", res.Status)
}
var signed signResponse
if err := json.Unmarshal(body, &signed); err != nil {
return "", err
}
if signed.URL == "" {
return "", fmt.Errorf("signing response did not include a URL")
}
return signed.URL, nil
}
func main() {
url, err := requestUploadURL(http.DefaultClient, os.Getenv("SIGNING_API_URL"), os.Getenv("API_TOKEN"), "avatars/tenant-7/object-abc/original")
if err != nil {
panic(err)
}
fmt.Println(url)
}
The code deliberately does not accept a URL from the browser and does not retry every failure. A retry policy belongs around idempotent control-plane operations and must respect the service's rate-limit signals; blindly retrying an upload-completion call can produce duplicate state or a retry storm. In production, return structured error classes to the client, attach a request ID to logs, and make the completion operation idempotent on the upload record.
What should the runbook verify before a storage cutover?
Start with a test tenant and a known private object. From the deployed browser origin, test the preflight and the signed upload, then confirm that a direct GET without a signed capability is denied. Ask an authorized user to resolve the avatar and fetch the returned URL; ask an unauthorized user to perform the same application request and verify that authorization fails before URL minting. Test an expired URL, a missing object, an object over the allowed size, and a second completion request. HTTP 403 is an expected denial signal in this test matrix, not evidence that the bucket should be made public.
Measure the things that page someone. Track signing latency, upload completion rate, incomplete multipart uploads, object-size policy rejects, download authorization rejects, and image-render failure rate. Correlate the application record, object key, and request ID without logging the full signed URL. A dashboard showing only stored bytes will miss the failure mode that users actually experience: an avatar record pointing at an object that was never completed.
The rollback should be boring. Keep the previous object key during the migration window, put URL resolution behind a feature flag, and switch reads to the former path if the new completion or delivery SLOs breach. Do not delete old objects until a representative traffic period has passed and reconciliation confirms that every database pointer has a valid object. I would also rehearse the rollback with one tenant; a plan that exists only in a document is not an operational control.
The trade-offs that should change the decision
| Option | Strength | Boundary to accept |
|---|---|---|
| Managed object storage | Direct uploads, durable object primitives, and less storage fleet maintenance | Provider-specific limits, egress policy, and access semantics still need validation |
| Self-hosted object storage | More control over placement, network, and operational policy | Your team owns capacity, upgrades, replication, repair, and on-call response |
| Application-proxied uploads | One place for validation and a simple client contract | API bandwidth and connection capacity scale with every uploaded byte |
| Direct signed uploads | Data-path load is separated from the API control path | CORS, expiry, completion, cleanup, and observability become explicit work |
The choice is not suitable when the product needs public, indexable media, minute-level metadata search, immutable retention, or automatic cross-region replication that the selected storage arrangement does not provide. Stick with an application-proxied path when files are genuinely tiny, the validation policy requires scanning before storage, or the team cannot yet operate the direct-upload state machine. For a large-file media workload, however, paying the API to relay bytes should require a clear security or compliance reason.
Your mileage may vary on the exact expiry and part size. Those values should come from threat modeling, client network behavior, and measured transfer failure rates, not from a copied sample. The stable rule is narrower: authorize in the application, transfer directly when throughput demands it, keep objects private, and make completion plus rollback observable.
Top comments (0)