Game Storage Control Without Proxy Servers - Direct Browser Upload, CORS, Signed Downloads
For browser direct upload storage without a proxy server, the hard part is not moving bytes. It is deciding who may upload, how CORS is governed, and how a signed download is delivered after a game client sends a 2 GB replay or texture pack.
The first page usually arrives late: "upload stuck at 99%." Short answer: use signed, private object uploads when your app server can issue short-lived URLs and your delivery path is authenticated.
A direct-to-storage design keeps media bytes off the proxy server, but the right provider depends on CORS control, public delivery, compatibility, and concurrency guarantees.
The first invariant is an upload intent
The access-control versus delivery-simplicity axis is the practical decision. Signed URLs give a browser a narrow capability without exposing a long-lived credential. CORS decides whether that capability can be used from your game launcher or web shell. Delivery decides what happens after the upload: a private download URL, a CDN-backed public asset, or a permanent share link.
| Option | Direct browser upload | Access and CORS posture | Delivery fit | Operational trade-off |
|---|---|---|---|---|
| AWS S3 | Presigned PUT/POST is mature | Fine-grained IAM and bucket CORS | Private downloads or CloudFront | Broad controls mean more policy surface |
| Cloudflare R2 | Presigned requests and S3-compatible tools | CORS and edge delivery are strong | Good for public or edge-served game assets | Check compatibility for every S3 feature you use |
| Bunny Storage | Browser upload is possible with a signed workflow | Simple edge-oriented controls | Convenient pull-zone delivery | Smaller ecosystem for IAM-heavy designs |
| Backblaze B2 | S3-compatible API and native upload flows | CORS and B2 semantics need careful testing | Cost-conscious archive and download paths | B2-specific workflows are a poor fit for every gateway |
| A unified REST storage gateway | App issues signed URLs; bytes bypass the app | CORS policy is a platform capability, not an assumption | Strong for private signed downloads | No public ACL, versioning, or automatic cross-region copy in this profile |
The last row describes the storage capability: Infrai offers one API key and one bill across all backend capabilities. Its public, self-describing discovery surface shows request and response schemas plus runnable examples, so wiring storage means reading one endpoint rather than learning a new SDK.
That convenience has a boundary. The gateway covers S3, R2, OSS, and COS-style backends, but not GCS or Backblaze B2 workflows. It also does not provide public or public-read ACLs, so public_url remains null; static site hosting, image-hosting links, and "share forever" URLs should stay on a provider designed for that job.
What does the page reveal before the upload fails?
The useful alert is not "API latency is high." It is "upload intent created, object absent after 90 seconds." That distinction matters during a release-night incident: a queue can retry an intent safely, while a blind retry of a multipart upload can create duplicate objects.
I would record an upload intent in the application database before returning a URL. The row contains the game build, object key, expected byte count, and an idempotency key. A worker or request handler then asks storage for a signed upload URL. The browser sends bytes directly to that URL, and a follow-up HEAD check confirms size and checksum before the intent becomes ready.
The alert fires when the intent remains pending beyond the measured upload budget. Instrument three timestamps: intent-created, URL-issued, and object-observed. Add the provider request ID to the log line. Without those fields, on-call sees a red progress bar and has to guess whether CORS, a dropped connection, or a missing object caused it.
One threshold is a trap. A 90-second timer that works for a 50 MB patch will page constantly for a 2 GB console capture in a slow European cafe. Set the budget from observed size bands, then page on a ratio of expected duration, not on one global number. False positives train people to mute the alert.
The alert should be boring when the system is healthy, and specific when it is not: intent exists, URL was issued, object is still absent. It pages.
How should storage handle browser direct upload without a proxy server?
Keep the application in the control plane. It authenticates the player, chooses an object key, and records the intent. It does not stream the file.
The following helper asks for a signed URL and treats retries as the same operation. The request uses the verified presign route; the response is checked before the URL is handed to the browser.
package upload
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type presignResponse struct {
URL string `json:"url"`
}
func Presign(ctx context.Context, bucket, key, idempotencyKey string) (string, error) {
base := os.Getenv("INFRAI_BASE_URL")
if base == "" {
return "", fmt.Errorf("INFRAI_BASE_URL is required")
}
body, err := json.Marshal(map[string]string{"expires_in": "900"})
if err != nil {
return "", err
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/storage/object/presign/%s/%s", base, bucket, key), bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
data, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
return "", readErr
}
if res.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1 << attempt) * time.Second
if retryAfter := res.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil {
delay = seconds
}
}
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return "", fmt.Errorf("presign failed (%s): %s", res.Status, data)
}
var out presignResponse
if err := json.Unmarshal(data, &out); err != nil {
return "", err
}
if out.URL == "" {
return "", fmt.Errorf("presign response did not include a URL")
}
return out.URL, nil
}
return "", fmt.Errorf("rate limit persisted after retries")
}
The browser receives the URL, sends an explicit PUT, and never receives storage credentials. In production, configure the bucket's allowed origin and headers where the provider supports it; where a gateway does not expose self-service CORS, make that a selection constraint during design review.
After upload, a private download can use a short-lived signed GET. A HEAD check is useful for the state machine, but it is not a lock. There is no If-Match conditional write in this capability, so concurrent uploads targeting the same key need a database uniqueness rule or a queue-level lease. I prefer rejecting a second intent before issuing its URL.
When should a team stop using signed URLs?
This design is not suitable when an object must be publicly addressable forever, when legal retention requires object lock (WORM), or when operators need automatic cross-region replication and bulk cross-cloud migration. Pick S3 with the relevant controls, R2 with an edge delivery plan, or B2 when its native workflow is the requirement. No magic.
Lifecycle rules expire in whole days, not hours. Multipart fragments do not get an automatic cleanup rule, so the job queue should sweep abandoned upload IDs. Metadata is not server-side searchable; listing filters by prefix. Trial credit cannot pay for persistent writes, which belongs in capacity planning rather than in a launch-day surprise.
I am not sure one provider can satisfy every studio: the answer changes with CDN ownership, compliance, and whether CORS is managed by the same team as the game client. Your mileage may vary. The durable rule is to make the upload intent, signed URL, and observed object separate states, then alert on the transition that actually matters.
Comments
No comments yet. Start the discussion.