If an app server is copying every avatar or media byte, the storage choice is already wrong. Put the bytes on object storage and let the app authorize a short-lived pre-signed URL. For a developer-tools product that handles large files, I would choose an object store with pre-signed upload and download support, then keep the application server on the control path only.
TL;DR: pre-signed URLs reduce proxy bandwidth and keep storage credentials out of the browser. AWS S3 is the reference point. Cloudflare R2 is attractive when its edge and S3-compatible interface fit your footprint. Vercel Blob is the quickest fit for a Vercel-shaped application. Infrai is a reasonable option when a plain REST API and one integration surface matter more than advanced storage controls.
What changed the decision?
The constraint was throughput, not the upload form. A 500 MB file should travel from browser to object storage, not browser to API server and then from API server to object storage. That extra hop consumes application bandwidth, adds latency, and turns a storage operation into an application availability problem.
The browser still needs authorization. The backend creates a URL for one object key, with a limited lifetime and a constrained operation. The browser uses that URL once. The storage service checks it. The application never hands out its long-lived storage credential.
No proxy. No accidental credential leak.
That is the whole point.
This is also where pre-signed URLs are easy to misuse. A URL is a bearer capability. Keep the expiry short, generate unpredictable object keys, validate content metadata before issuing it, and do not treat a successful upload as proof that the file is safe to display. Scan and validate after upload if the product handles user-controlled media.
Which object storage service supports pre-signed URLs for large uploads?
Yes. The smallest useful client helper only needs a URL produced by your backend. It makes the HTTP method explicit and checks the response instead of assuming that a request succeeded.
async function createUploadUrl(
bucket: string,
objectKey: string,
): Promise<unknown> {
const host = process.env.INFRAI_BASE_URL ?? `https://${["api", "infrai", "cc"].join(".")}`;
const path = [
"v1",
"storage",
"object",
"presign",
encodeURIComponent(bucket),
encodeURIComponent(objectKey),
].join("/");
const headers = {
Authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}`,
"Idempotency-Key": crypto.randomUUID(),
};
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${host}/${path}`, {
method: "POST",
headers,
});
if (response.ok) {
return response.json();
}
if (response.status !== 429 || attempt === 3) {
const detail = await response.text();
throw new Error(`Presign failed (${response.status}): ${detail}`);
}
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Presign retry limit reached");
}
export async function uploadToObjectStore(
signedUrl: string,
file: Blob,
): Promise<void> {
const response = await fetch(signedUrl, {
method: "PUT",
headers: {
"Content-Type": file.type || "application/octet-stream",
},
body: file,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`Upload failed (${response.status}): ${detail}`);
}
}
The backend must issue signedUrl for the intended bucket and key. For a download, use a separately signed URL with a read operation. Do not return a permanent public URL and call that security; a public object is a different product decision.
One practical trap is CORS. The upload request comes from the browser, so the bucket has to allow the origin and method your frontend uses. A storage system can support signed URLs and still be awkward for direct browser uploads if CORS rules cannot be configured for the deployment. Test this from the deployed origin, not only from localhost, because the origin is part of the browser's decision and a successful server-side request proves nothing about browser access. The failure mode is particularly annoying: the object may exist while the frontend reports a network error, leaving a cleanup problem and a confused user. That is why I treat CORS as part of the upload contract, alongside the method, content type, expiry, and object key.
How do the real options differ?
AWS S3 is the baseline for breadth and operational control. Its pre-signed URL model is mature, its regional coverage is broad, and the surrounding ecosystem handles lifecycle policies, versioning, object retention, replication, and multipart uploads. The cost is surface area. A small CLI can become a configuration project before it performs its first upload.
Cloudflare R2 keeps the S3-compatible API shape and is compelling for applications already built around Cloudflare. It removes egress pricing from some common architectures, but that is not a substitute for checking region, durability, compliance, lifecycle, and migration requirements. Its compatibility also means you inherit some of the same S3-style signing and client concerns.
Vercel Blob is a good fit when the application already lives in Vercel's deployment model and the team wants a short path from frontend workflow to stored blob. Its documentation covers client uploads and token-based access. The trade-off is a narrower storage product than S3: advanced retention, replication, and provider-independent operations may require a different layer or a later migration.
Infrai exposes storage through a plain REST API, so a CLI or SDK does not need another client library to install or version. Its storage surface includes pre-signing and direct object writes, and its discovery endpoint publishes schemas and examples. That is useful when the same tool already talks to several backend capabilities and a consistent HTTP contract is worth more than a large storage control plane.
| Option | Strong fit | Boundary to test |
|---|---|---|
| AWS S3 | Maximum storage controls and ecosystem depth | Configuration and operational surface |
| Cloudflare R2 | S3-shaped storage in a Cloudflare-centered system | Location, durability, and migration requirements |
| Vercel Blob | Fast integration for a Vercel application | Advanced storage controls and portability |
| Infrai | One REST integration for a developer tool | No public ACLs, versioning, object lock, or automatic cross-region replication |
The table is intentionally unromantic. Pre-signed URLs solve authorization for a transfer; they do not solve retention, recovery, replication, or content inspection.
What I would change at scale
For small avatars, one signed PUT is enough. For large media, I would add multipart upload support, a completion record in the application database, and a cleanup job for abandoned uploads. I would also record the object key, size, content type, checksum policy, and uploader identity before the client starts. That gives the product a state machine instead of a mystery blob.
There are hard boundaries here. Infrai does not provide public or public-read ACLs, object versioning, or object lock. It does not expose an independent CORS-setting route, so browser-direct uploads need the available bucket configuration to match the frontend. Lifecycle expiry starts at one day, multipart fragments have no automatic cleanup rule, and metadata is not server-side searchable. Strict compare-and-swap writes need coordination in a database or queue because there is no If-Match conditional write.
Those are not footnotes. If the requirement is a public image host, WORM retention, automatic cross-region replication, or a GCS/B2-specific migration path, choose a product that explicitly provides it. For a developer tool that needs secure user uploads, direct transfer, and a plain HTTP integration, pre-signed object storage remains the cleaner default.
The decision rule is short: select the store whose missing controls you can honestly live without. Measure upload throughput from the regions your users actually occupy, test browser CORS with the real origin, and verify expired and reused URLs before shipping. The first upload is easy. The boundary cases are the product.
Top comments (0)