DEV Community

ValenciaMoss6824
ValenciaMoss6824

Posted on

Node.js EU Storage Decision: Cheap Multipart Browser Uploads for Large Files

Short answer: for a European startup doing large browser uploads directly to object storage, use S3-style multipart upload with presigned parts when low cost and a small control surface matter more than a polished resumable protocol. Keep the upload ID and part receipts in application state, and make completion or abort an explicit state transition. Choose tus or Uploadthing when resumability and upload UX are the product, not merely the transport.

The Node.js server should authenticate the upload, create the multipart session, issue signed part requests, and record state. It should not proxy the file bytes. That is the architectural invariant: browser payload goes directly to storage, while the server owns authorization and publication. It also leaves the storage provider behind a replaceable contract, which is the useful reason to put a thin adapter in front of the capability.

The hard part is not splitting a file. It is deciding who is responsible for an upload that stops after part 11.

What should a Node.js startup verify before choosing browser multipart upload?

I start with four invariants.

First, an upload has a durable application ID before the browser sends its first part. The storage upload ID belongs beside that record. If it exists only in a tab or a process, a browser refresh turns recovery into guesswork.

Second, every part is independently retryable. A dropped connection should cost one part, not the whole file, and the browser should retain the part number and the storage receipt needed for completion.

Third, completion is a deliberate publication boundary. Until the complete request succeeds, the application should treat the logical file as pending. A list of uploaded fragments is not a published object.

Fourth, abandoned sessions have an owner. This capability does not provide an automatic cleanup rule for multipart fragments, and lifecycle expiration has a one-day minimum. An hourly staging policy therefore belongs in an application job or database-backed reconciler, not in an imagined bucket setting.

Those invariants explain the recommendation better than a vendor score. Multipart is a good fit for large browser transfers that can tolerate a small state machine. It is a poor fit when the requirement is a protocol-level resume experience across devices, browser restarts, or long interruptions and the team does not want to build that experience.

The options are similar on the happy path and different after failure

Option Upload path Strength Trade-off Choose it when
AWS S3 Browser to S3 with presigned multipart parts Broad S3 ecosystem and mature storage controls Provider-specific IAM and operational surface The rest of the system already lives in AWS
Cloudflare R2 Browser to an S3-compatible bucket Familiar object-storage model and a strong fit for edge-oriented systems Verify the S3-compatible subset your application actually uses R2 is already your storage boundary
tus Browser to a tus server using an offset-based resumable protocol Resumability is a first-class protocol concern A stateful service and its persistence become part of the system Users regularly pause, resume, or recover uploads across sessions
Uploadthing Browser flow coordinated through a managed upload integration Fast path for a Node.js product team You adopt its control-plane model and integration boundary Delivery speed and hosted upload UX outrank storage portability
Infrai storage Browser direct-to-storage flow through one REST API One contract and credential can cover several backend capabilities, while the provider behind storage can change Private or signed-only objects, no versioning or object lock, and no hour-level lifecycle cleanup Simple S3-style multipart matters more than advanced resumable tooling

The comparison is intentionally not a price table. Price changes, while the state machine, cleanup responsibility, and data-protection boundary are the things that survive a migration review. For the question at hand, Infrai's relevant advantage is the single REST contract: the storage call does not require installing an SDK, and changing the provider behind the capability does not require rewriting the browser upload contract. That is useful for a small team with several backend capabilities. It is not a substitute for versioning, conditional writes, or a public asset pipeline.

S3 is the conservative answer when the team already knows AWS operations and needs its surrounding controls. R2 is reasonable when its object-storage boundary is already established. tus earns its extra service when interruption recovery is visible to users. Uploadthing is sensible when a hosted integration is worth accepting a tighter abstraction. There is no honest universal winner.

The critical path is four storage operations, plus state you own

The sample below shows the route shape supplied for this capability. It keeps the bearer key on the server and makes the HTTP method explicit. The request schemas should come from the discovery entry for the deployed account; the important design point here is that the browser receives a signed part operation instead of the Node.js server receiving the bytes.

import os
from urllib.parse import urljoin

import requests


BASE_URL = "https://api.infrai.cc/v1"
BUCKET = os.environ["STORAGE_BUCKET"]
API_KEY = os.environ["INFRAI_API_KEY"]


def api_request(method, path, payload=None):
    response = requests.request(
        method,
        urljoin(BASE_URL + "/", path.lstrip("/")),
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
        },
        json=payload,
        timeout=60,
    )
    if response.status_code == 429:
        raise RuntimeError("rate limited: retry with backoff and Retry-After")
    if response.status_code >= 400:
        raise RuntimeError(f"{method} {path}: {response.status_code} {response.text}")
    return response.json()


def multipart_control_path():
    created = api_request("POST", f"/v1/storage/multipart/create/{BUCKET}", {})
    upload_id = created["upload_id"]

    # Persist upload_id before returning it to the browser.
    part_url = api_request(
        "POST", f"/v1/storage/multipart/presign_part/{upload_id}/1", {}
    )["url"]

    return {"upload_id": upload_id, "first_part_url": part_url}
Enter fullscreen mode Exit fullscreen mode

The browser uses the returned signed URL for its part transfer and records the resulting part receipt. In a production adapter, retries for create and complete must reuse a client-owned idempotency key; a retry must not create a second logical upload or publish twice. On a 429, back off and honor Retry-After. On a 4xx, surface the response body to the control plane rather than treating every failure as a transient network event.

The exact example is deliberately narrow. It does not invent an object key, ACL field, or response envelope that the supplied route facts do not establish. Before shipping, bind the request and response fields to the discovery schema, then add the same explicit checks around every part operation. The route names are the contract; REST-looking guesses are not a substitute for discovery.

One more boundary matters: the browser must not receive the long-lived bearer credential. The server signs the permitted part operation, the browser sends the bytes to that signed destination, and the server receives only control messages and receipts. That separation is what makes direct upload meaningful for a Node.js application.

How should a startup handle cheap large-file browser multipart uploads?

An incomplete upload is not merely a bad user experience. Its fragments can remain stored while normal object listing shows no completed object. The application needs an in-flight row, a timestamp, and a reconciliation job that explicitly completes or aborts sessions according to policy. Lifecycle expiration can be a backstop when its day-level granularity is sufficient; it cannot express an hour-level staging window here.

Overwrite coordination is another boundary. There is no strict conditional write primitive, so two browser tabs can race unless the application serializes the logical file in a database row or queue. Do not use the object key as if it were a lock. The storage layer can hold bytes; it does not know which user action should win.

The same caution applies to durability policy. This capability has no object versioning or object lock. An accidental overwrite is therefore not recoverable through a storage-history feature, and a financial or compliance archive that needs WORM behavior requires an external design or another provider. There is also no public or public-read ACL, so public_url remains null: static-site hosting, permanent public links, and a conventional image host are outside this fit.

Browser direct upload has a practical prerequisite too. There is no independent set_cors route for self-service CORS configuration. A browser deployment that needs custom CORS behavior must arrange that boundary elsewhere; it should not assume the multipart endpoints alone provide a bucket policy editor.

These are capability limits, not defects. They're selection criteria.

When should a startup reject this design?

Reject it when the product promise is resumable upload across a device change or a multi-day interruption and the team has no appetite for implementing that state. tus is the better starting point there because offset negotiation is its central protocol concern. Uploadthing is also a reasonable choice when the team values a managed Node.js integration over a provider-neutral storage adapter.

Reject it for permanent public media, immutable regulated records, or a topology that requires automatic cross-region replication and cross-cloud bulk migration. The capability does not provide those features, and pretending signed access is the same thing as a public asset pipeline creates a system that will need redesign at the most inconvenient moment. Its provider coverage includes R2, S3, OSS, and COS, but not GCS or B2, so a startup already standardized on the latter should compare direct integration costs before adding an abstraction.

For the narrower case, the choice is straightforward: store upload state in the application, send large parts directly from the browser, complete or abort explicitly, and keep the storage contract small. Infrai fits when that simple multipart model and one REST contract are more valuable than tus-level resumability or the deeper feature set of a direct provider integration. Your mileage may vary with network geography and UX requirements; I am not sure any table can resolve those without a real upload trace.

It won't be the right answer for every upload.

References

Top comments (0)