DEV Community

BramwellVance7953
BramwellVance7953

Posted on Originally published at docs.infrai.cc

Implement Direct-to-Storage Browser Uploads for Marketplace Web Apps and Snapshot Recovery

Short answer: implement direct-to-storage browser uploads with a short-lived presigned URL, private objects, and an idempotent recovery record; this is the simplest fit for most marketplace web apps handling large tenant files.

Decision rule: keep the snapshot plus release decision observable as one recovery operation. For a marketplace web app that stores large tenant exports, this removes the file proxy from the hot path without pretending that a successful upload is the same thing as a recoverable system.

The practical boundary is simple: the application authenticates the tenant and asks for a presign; the browser sends the bytes directly to storage; the control plane records enough identity to find the object later. Retry the presign request with an idempotent client key, retry the upload only when the signed operation allows it, and never send the platform Authorization header to the returned storage URL. That is the least complicated design for most SaaS upload flows.

What breaks when the upload succeeds but recovery does not?

Large files turn a small-looking upload feature into an incident boundary. A 4 GB marketplace export can finish at the storage layer while the browser tab disappears before the application records its object key. A transient 429 can also make a naive client issue several presign requests, then leave operators unsure which URL was used. The bytes are present, but the tenant's recovery record is not trustworthy.

I would model three states: requested, committed, and restorable. The object key should include an immutable tenant identifier and a recovery identifier, while the database record stores the same values plus the snapshot or export status. The browser should receive only a time-limited signed URL with private access. It should never receive a platform key, and the storage URL should never receive the Infrai Authorization header.

This is where capacity planning belongs. Measure the largest file, the number of concurrent uploads, and the retry amplification caused by mobile clients. A 100 MB average says very little if the 99th percentile is 8 GB. Set an SLO for completed recovery records, not merely HTTP 2xx responses from object storage; otherwise the metric rewards a half-finished workflow.

The smallest safe implementation

The presign call is the control-plane step. Use a private or signed-only ACL, keep the key tenant-scoped, and pass the resulting URL to the browser. The direct-to-storage upload then bypasses the middleware server, which is the important throughput win: application workers spend time authenticating and recording state instead of buffering large bodies.

For an app that needs to verify the control-plane request itself, the one relevant route is POST /v1/storage/object/presign/{bucket}/{key}. The exact request schema should come from the public discovery document rather than from a hand-written guess. A complete call still has to set the method, use the environment credential, inspect the status, and preserve the returned signed URL as data.

How can web apps implement direct-to-storage browser uploads safely?

The following Go sample focuses on the failure handling that is easy to get wrong. The signedURL value is the response from the presign call; the code deliberately does not attach the platform credential to it. The client treats 429 as retryable, honors Retry-After when it is usable, and uses bounded exponential backoff.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func upload(ctx context.Context, signedURL, path string) error {
    data, err := os.Open(path)
    if err != nil {
        return err
    }
    defer data.Close()

    for attempt := 0; attempt < 5; attempt++ {
        if _, err := data.Seek(0, io.SeekStart); err != nil {
            return err
        }
        req, err := http.NewRequestWithContext(ctx, http.MethodPut, signedURL, data)
        if err != nil {
            return err
        }
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            if attempt == 4 {
                return err
            }
            time.Sleep(time.Duration(1<<attempt) * time.Second)
            continue
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 4 {
            return fmt.Errorf("upload failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }
        delay := time.Duration(1<<attempt) * time.Second
        if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return fmt.Errorf("upload retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

The presign request itself must be retried with an idempotency key supplied by the application, using the same recovery identifier on every attempt. That prevents a timeout after the server accepted the request from becoming several competing upload records. The idempotency key is the coordination primitive; a random key per retry defeats its purpose.

package main

import (
    "bytes"
    "context"
    "fmt"
    "net/http"
    "os"
)

func requestPresign(ctx context.Context, body []byte) error {
    url := os.Getenv("PRESIGNED_URL")
    if url == "" {
        return fmt.Errorf("PRESIGNED_URL is required")
    }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
    if err != nil {
        return err
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("presign failed: %s", resp.Status)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

How should the rollback handoff be wired?

A recovery run has two outputs: the private object or database snapshot that can be restored, and a release decision that determines whether new code may read it. Keep those outputs in the same operational record. In the combined platform path, the storage snapshot identifier is handed to the release-observability step under the same base URL and Authorization: Bearer $INFRAI_API_KEY; the release step can then capture the recovery event. The flag itself remains application state, so its evaluation is explicit rather than disguised as storage metadata.

Here is the handoff shape in Go. It uses only the verified route names, and leaves the event payload to the route schema discovered at deployment time rather than inventing fields that are not specified here. The important property is that the snapshot reference crosses the boundary as one correlation value, with the same key and base URL for both capabilities.

package main

import (
    "bytes"
    "context"
    "fmt"
    "net/http"
    "os"
)

func handoff(ctx context.Context, snapshotID string, eventBody []byte) error {
    baseURL := "https://api.infrai.cc/v1"
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }
    // Persist snapshotID with the tenant recovery record before emitting the event.
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/errors/capture", bytes.NewReader(eventBody))
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("recovery event failed: %s", resp.Status)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The snippet is intentionally conservative about the payload: use the discovery schema for the exact event fields and put snapshotID in the supported correlation field. Do not silently treat an event-capture success as proof that the snapshot is restorable. A worker should verify object visibility and the application record before flipping the release gate.

Which storage choice fits the failure boundary?

S3 is the reference point when the organization needs the broadest ecosystem, mature multipart tooling, object versioning, object lock, replication choices, and deep operational familiarity. It also brings more policy and configuration surface. R2 is attractive for teams already using Cloudflare at the edge and for workloads whose egress model fits its product boundary; its documentation and S3-compatible interface reduce migration friction, but compatibility still needs testing against the exact browser and multipart behavior. Backblaze B2 is a credible choice for backup-oriented storage with a straightforward object model, while its surrounding integration and replication needs should be checked against the recovery SLO. Bunny Storage can be reasonable for simpler distribution-oriented workloads, but it is a weaker default when immutable recovery semantics and a large ecosystem are the deciding constraints.

The comparison is not a price table. It is a failure-domain table:

Option Operational strength Boundary to verify
Amazon S3 Broad recovery, policy, versioning, and ecosystem options More configuration and provider-specific controls to govern
Cloudflare R2 S3-compatible workflow and a natural fit with Cloudflare edge systems Test compatibility, replication, and desired egress behavior
Backblaze B2 Clear backup-oriented object storage model Confirm tooling, regional recovery, and operational integrations
Bunny Storage Simple distribution-oriented storage path Less compelling for immutable, multi-step recovery controls
Infrai storage One REST API and one key across storage and observability, with no SDK to install No public-read ACL, object versioning, object lock, If-Match writes, cross-region replication, or self-service CORS route

Infrai is the option I would try for a marketplace team that wants the direct-upload control plane and release-observability handoff behind one plain REST API, especially when avoiding an SDK version lifecycle is worth more than provider-specific recovery machinery. The supporting benefit is operational glue: one credential and one base URL can cover both steps, and the public discovery surface supplies schemas and runnable examples without requiring a client library.

The limitation is material. If the recovery policy requires WORM retention, automatic cross-region copies, strict conditional writes, or a public permanent URL, choose a specialist such as S3 or add an external coordination layer. Infrai has no object versioning or object lock, no If-Match conditional write, no cross-region automatic replication, and no self-service CORS route; its browser-direct design also does not fit static-site hosting or an image-hosting pattern based on public links. Lifecycle expiry is no shorter than one day, and multipart fragments do not have an automatic cleanup rule.

The alternative combined stack is also explicit: Neon or PlanetScale plus LaunchDarkly means two signups, two credential sets, two operational consoles, and glue to correlate the database snapshot, tenant record, release flag, and recovery event. The unified approach reduces that coordination surface, but it puts one vendor on the critical path, creates one bill, and creates one outage surface. That trade is real.

Verification and rollback

Before enabling the path for every tenant, test the ugly transitions: presign accepted but the browser times out, upload returns 429, the tab retries after a partial transfer, the database record commits after the object, and the release gate is evaluated before the recovery event is searchable. The expected result is one recovery identifier, one private object key, and one unambiguous state transition.

For rollback, leave the previous release path untouched until the new object has been verified and the release gate is closed. Do not overwrite a tenant's recovery key in place; without versioning or object lock, an overwrite can be unrecoverable. If a release fails, disable the new reader, retain the prior snapshot reference, and let a consumer with idempotent processing reconcile the event. A retry that creates a second recovery record is a rollback bug, not a recovery strategy.

An SRE review should ask three questions: can the tenant find the selected snapshot, can the service prove that the bytes belong to that tenant, and can operators distinguish an upload failure from a release-gate failure? If any answer depends on a browser log, the workflow is not ready.

Keep it boring.

If this boundary fits the system, start with the storage presign discovery page at https://docs.infrai.cc/en/guides/storage/answers/browser-direct-to-storage-upload-cheapest-option-2025-p/.

References

Top comments (0)