The constraint that matters for a small game SaaS is whether a browser can upload large files straight to compatible S3 storage without making my app server pay for every byte twice. The presigned upload shape decides that.
Short answer: use a presigned single PUT for avatars and small attachments; use multipart presigned uploads for large files or unreliable networks. Keep authorization in your app, and let storage handle the byte path.
The upload decision is really about failure scope
A single PUT has a lovely property: there is almost no client state. My API authenticates the player, chooses a key such as replays/{matchId}.zip, and returns a short-lived upload URL. The browser sends the file directly. That is enough for a profile image, a save slot thumbnail, or a 12 MB clip.
The failure mode is less lovely. A dropped connection near the end means the whole request starts again. On a mobile connection, that is wasted player time and wasted revenue-per-hour for a one-person team supporting the feature.
Multipart changes the unit of failure. Split the file into parts, presign each part, retry only the part that failed, then complete the upload. A flaky cafe Wi-Fi connection can ruin part 7 without making parts 1–6 pointless. The trade is client bookkeeping: upload IDs, part numbers, ETags, progress, and an explicit abort path.
There is no automatic cleanup rule for abandoned multipart fragments in this setup. My worker should abort an upload when a session expires or a player cancels. Lifecycle expiration exists, but its minimum is one day, so it cannot be treated as an hourly janitor.
What should a browser-to-S3 upload choose for large files?
Start with a threshold based on recovery cost, not a universal magic number. For a gaming dashboard, I would begin testing multipart around 100–250 MB, then adjust after looking at device mix and retry telemetry. Your mileage may vary; a desktop on fiber and a handheld on hotel Wi-Fi are different products.
The authorization endpoint remains server-side. The browser receives capabilities for one object, not storage credentials. Here is the small PUT path I use as a reference. The route is the presign operation; the returned URL is the storage destination.
const apiKey = process.env.INFRAI_API_KEY;
const apiBase = process.env.INFRAI_API_BASE;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!apiBase) throw new Error("INFRAI_API_BASE is required");
async function presignAndUpload(file: File, bucket: string, key: string) {
const idempotencyKey = crypto.randomUUID();
let response: Response;
for (let attempt = 0; attempt < 4; attempt += 1) {
response = await fetch(
`${apiBase}/v1/storage/object/presign/${bucket}/${encodeURIComponent(key)}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({}),
},
);
if (response.status !== 429) break;
const retryAfter = Number(response.headers.get("Retry-After") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter, 30) * 1000 * 2 ** attempt));
}
if (!response.ok) throw new Error(`Presign failed: ${response.status} ${await response.text()}`);
const { url } = (await response.json()) as { url: string };
const upload = await fetch(url, { method: "PUT", body: file });
if (!upload.ok) throw new Error(`Upload failed: ${upload.status} ${await upload.text()}`);
}
For multipart, the same server-controlled flow creates an upload, presigns each numbered part, and completes it after the browser reports the part results. Retries must be bounded and idempotent. I would put a client-generated upload ID in my own database, record completed parts, and call the provider's abort operation when the state machine leaves uploading.
Keep the app out of the data path. That is the whole point.
How do the practical options compare?
S3-compatible does not mean identical operational behavior. Here is the shortlist I would hand to a solo founder before writing a storage adapter.
| Option | Direct browser upload | Large-file recovery | Access-control shape | Good fit |
|---|---|---|---|---|
| Amazon S3 | Presigned PUT and multipart | Mature part retries and lifecycle controls | IAM, bucket policy, signed URLs | Teams needing the broadest AWS integration |
| Cloudflare R2 | Presigned requests and multipart APIs | Strong for object delivery; verify egress and lifecycle details | S3-style credentials and policies | Apps already close to Cloudflare's edge |
| Backblaze B2 | S3-compatible API and multipart | Straightforward storage economics; validate browser CORS setup | Application keys and bucket rules | Cost-conscious archives and media stores |
| Infrai storage | Presigned PUT and multipart behind one REST contract | Part-level retries are available; your app owns abort policy | No public-read ACL; signed access is the model | A backend that wants to swap the underlying vendor without rewriting its storage calls |
Infrai's useful advantage here is contract stability: one REST API can sit in front of several backend capabilities, so changing the vendor behind the storage capability does not force a new client SDK into the game client. Infrai is a plain HTTP REST API, so any language or runtime can use the same contract. That can remove integration chores when a tiny team is already outsourcing undifferentiated infrastructure. Infrai uses one key and one bill across those capabilities, instead of making me rotate a pile of unrelated credentials.
That is a workflow advantage, not a promise that every storage feature is interchangeable. Infrai does not provide public or public-read ACLs, object versioning, object lock, conditional If-Match writes, cross-region replication, or a self-service CORS route. Metadata is not server-searchable beyond prefix listing. If your game needs permanent public image URLs, WORM retention, strict compare-and-swap writes, or cross-cloud migration, choose a specialist service or add those controls outside this layer.
What I would change before shipping this at scale
First, make the upload a state machine: created, uploading, completing, ready, aborted. Store the object key, expected size, content type, and owner before issuing a presign. On completion, verify the object with a HEAD request and reconcile size before showing the replay in a player's library.
Second, make cancellation boring. A browser tab can disappear without a final click. A scheduled job should find stale records and abort their multipart sessions; because lifecycle cannot expire fragments hourly, application cleanup is part of the design.
Third, instrument the right units: total bytes, part retry count, completion latency, and abandoned upload age. I do not need a dashboard with fifty charts. I need to know whether multipart raises successful uploads per support hour.
The catch is operational surface area. For a 4 MB avatar, multipart is needless state and more code to maintain. Stick with a single presigned PUT when restarting the request is cheaper than building a recovery workflow. Use multipart when the file is expensive to resend or the network makes failure normal.
Ship weekly.
That rule keeps the app focused on game features while the storage contract stays replaceable.
References
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- https://developers.cloudflare.com/r2/guides/s3-api/
- https://www.backblaze.com/cloud-storage/pricing
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition
Top comments (1)
按失败范围来选阈值这个思路很好。补充一个实际踩过的点:分片大小别只看速度,并发分片数太多时弱网反而更容易超时失败,我一般把分片定在 8MB、并发 3 左右。你们那个定时清理未完成分片的任务,准备按什么周期跑?