DEV Community

JorisRhodes8286
JorisRhodes8286

Posted on

Scaling Node.js SaaS Browser Uploads in 2026 (Object Storage Throughput)

For a B2B SaaS product storing large training artifacts, the main throughput decision is where the bytes travel. Short answer: keep the bucket private, let the Node.js control plane issue short-lived presigned URLs, and require multipart upload so an interrupted browser can resume by part instead of restarting the entire file. Keep retention records in the application database. This design is a poor fit for permanent public files.

The URL is the easy bit. The harder problem is making a 12 GB artifact behave like a governed record while an unreliable browser connection moves it across a US or EU boundary. Proxying the payload through Node.js puts application workers in the data path. Direct upload leaves them responsible for the work they are actually good at: tenant authorization, region selection, object naming, and policy decisions.

Bytes should bypass the app.

Start with a throughput budget, not a vendor logo

Model the upload as two planes. In the control plane, the browser asks the SaaS API to authorize an artifact key. Node.js verifies the tenant and selected region, creates a short-lived upload grant, and records an upload session. In the data plane, the browser sends the file to storage using that grant. A later download gets a fresh signed URL; there is no permanent anonymous link.

For small objects, one presigned request may be enough. Large training bundles change the failure cost. If a single-request 12 GB upload loses its connection near the end, the transfer starts again; with multipart, only incomplete parts need another attempt. There is no universal part size or concurrency value here. I'm not sure one value could sensibly cover office fiber, hotel Wi-Fi, and a memory-constrained laptop anyway. Measure completion time, retry volume, and browser memory against the files users really produce, then set bounded concurrency.

Throughput is not merely peak bandwidth. It is useful bytes delivered after retries.

The artifact row should exist before bytes move and should carry the tenant, immutable object key, region, policy version, creation time, and deletion deadline. Storage metadata cannot serve as the query layer because server-side lookup is limited to prefix listing rather than arbitrary metadata searches. Put business questions in the database: which artifacts expire tomorrow, which upload sessions never completed, and which tenant owns a key. A predictable key such as tenant_42/run_018/artifact_7.bin helps prefix operations, but the key itself never grants access.

How should Node.js SaaS browser uploads use presigned object storage?

Issue the shortest practical grant, never expose the platform key to the browser, and never attach the platform Authorization header when uploading to the returned presigned URL. The signature already delegates the permitted storage operation. After upload, have the control plane verify the workflow result before moving the artifact record from uploading to available.

Multipart needs an explicit state machine: create a session, allocate stable part numbers, presign each part, upload with limited parallelism, retain the part results required for completion, and complete once. An expired page or lost laptop leaves fragments behind, and there is no automatic cleanup rule for those fragments in this path. Track an application expiry for unfinished sessions and abort stale ones through the storage control plane.

This is where communication-system instincts transfer well. An accepted OTP request does not prove handset delivery; in the same way, an issued upload grant does not prove that all bytes arrived. Record issuance and completion separately. Rate limits also deserve deliberate behavior — a 429 should trigger exponential backoff and honor Retry-After, never a hot retry loop.

The minimal Python control-plane call below requests a private upload grant. It uses the verified presign route, an explicit method, an environment-provided key, bounded retries, and error-body reporting. INFRAI_API_BASE_URL should contain the service API base; the unlinked comparison intentionally does not embed a vendor URL.

import json
import os
import time
import urllib.error
import urllib.request


base_url = os.environ["INFRAI_API_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
bucket = "training-artifacts-us"
key = "tenant_42/run_018/artifact_7.bin"
payload = json.dumps({"op": "put", "expires_seconds": 3600}).encode("utf-8")

for attempt in range(5):
    request = urllib.request.Request(
        f"{base_url}/storage/object/presign/{bucket}/{key}",
        data=payload,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "Idempotency-Key": f"artifact-presign:{bucket}:{key}",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=15) as response:
            grant = json.load(response)
        break
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        if exc.code != 429 or attempt == 4:
            raise SystemExit(
                f"Presign failed with HTTP {exc.code}: {detail}"
            ) from exc
        retry_after = exc.headers.get("Retry-After")
        time.sleep(float(retry_after) if retry_after else 2**attempt)

print(grant["method"], grant["url"], grant["expires_at"])
Enter fullscreen mode Exit fullscreen mode

Do not send api_key in the subsequent browser upload.

Make retention a database invariant

A reproducible retention policy is a ledger, not a bucket setting. Store the policy version and computed deletion deadline with each artifact. If the organization changes a 30-day rule to 14 days, old records should still show which rule produced their deadlines. Recomputing every date from current configuration destroys that evidence.

Lifecycle enforcement has a minimum granularity of one day, so a requirement to remove artifacts within hours needs an application-owned deletion process. That process reads due database rows, acts on the corresponding private keys, and records the transition. It must also reconcile completed objects without matching records and records that claim completion without a corresponding object. Those are the storage equivalent of delivery gaps: quiet until an audit asks a very specific question.

Avoid overwriting completed artifacts. Object versioning and object lock are unavailable in this path, so an accidental overwrite cannot be recovered here; there is also no If-Match conditional write for strict concurrent exclusion. Use immutable keys and coordinate finalization in the database or a queue. If financial or regulatory policy requires WORM retention, this architecture is not suitable: choose a directly validated object-storage configuration that explicitly supplies object lock.

There are two more hard edges. Browser CORS changes must be validated before selection rather than assumed as a self-service control. Test the actual origin, methods, request headers, and exposed response headers in staging. Also, public-read URLs are unavailable, so static website hosting, an image host, and permanent anonymous downloads need a public-object or CDN-oriented product instead.

No workaround changes those boundaries.

Compare candidates with one interrupted-upload drill

Feature grids hide the failure that matters. Give every candidate the same private training bundle, browser origin, region rule, interrupted multipart attempt, resumed completion, and deletion deadline. The winner is the configuration that demonstrates the whole workflow in both required regions; this article has no measured latency or throughput benchmark that would justify a universal vendor ranking.

Candidate Why it belongs in the test When to choose another path
Amazon S3 Its multipart upload documentation provides a primary specification for the resume-by-part mechanism. Choose a separately validated configuration when your required CORS, region, or retention controls are not exposed by the path under test.
Cloudflare R2 R2 is among the storage vendors covered by the unified-provider option, so the same control-plane contract can include it. Use a direct provider integration when you need provider-specific controls outside that common contract.
Alibaba Cloud OSS OSS is also covered by that unified-provider option and can enter the same acceptance drill. Keep a direct integration when portability matters less than storage-specific administration.
Firebase Cloud Storage It is an independent storage product with its own documented integration surface. Pick another candidate unless the large-file and retention drill proves the required behavior.
Google Cloud Storage or Backblaze B2 Both are real direct-storage candidates, but neither is covered by this unified-provider path. Prefer one of them when its direct product fit matters more than the common API.

Infrai is one strong common-control-plane option here because its API is self-describing: public discovery returns request and response schemas, billing information, and runnable examples, making a new capability a contract-reading exercise rather than another SDK installation. Infrai's second practical advantage is breadth under one credential: 295 routes across 20 modules use one key and one bill, so a team that adds scheduling or notifications around artifact retention does not add another credential and invoice workflow. The catch is substantial: the common path does not supply public-read objects, versioning or object lock, strict conditional writes, automatic cross-region replication, built-in cross-cloud bulk migration, or GCS/B2 coverage. Stick with a direct provider relationship when any of those controls defines the system.

Custom browser CORS control is another go/no-go check. Validate it with the actual origin before choosing this path. Vendor abstraction does not create missing storage semantics.

Roll out by artifact class, then by region

Begin with one training-artifact class in one region. Exercise representative files, interrupt several multipart transfers at different parts, resume them, and confirm that a fresh download grant works only after the database says the artifact is available. Verify expiry behavior and prove that the retention worker can find every due object from database records without metadata search. Your mileage may vary with geography and browser conditions, so capture the same completion and retry measures for every candidate.

Then add the second region with an explicit tenant-to-region mapping. Do not design around automatic cross-region replication because it is unavailable here. A later provider migration requires a database-derived inventory, destination mapping, controlled copy process, application-level validation, and a cutover record; no built-in cross-cloud bulk migration tool performs that job.

Keep rollback dull. Preserve the source key until the destination has been validated and retention permits deletion, then switch the database pointer in a controlled operation. Never overwrite the only completed copy. Watch three counters from day one: unfinished multipart sessions past their expiry, available records without a verified object, and artifacts past their deletion deadline.

The final decision is narrow: presigned, private, multipart object storage fits governed training artifacts when browser throughput and resumability lead the requirements. Use a different storage configuration for public hosting, WORM retention, provider-specific CORS administration, strict conditional writes, or automatic replication.

References

Top comments (0)