Practical patterns — from simple presigned PUTs to edge-accelerated, resumable, and zero-trust flows — that keep uploads fast, safe, and cheap.
Nine production-ready file upload designs using S3, Cloudflare R2, and signed URLs. Learn when to use presigned PUT/POST, multipart, resumable, edge, and zero-trust pipelines.
You ship a file upload. It works on your Wi-Fi. Then a customer in another region tries a 2 GB video on mobile, and suddenly your server falls over or the browser hangs at 92%. Let's be real: uploads are a networking problem wrapped in product expectations. Below are nine designs I keep reaching for in 2025 — each tuned for scale, predictability, and cost.
1) Direct-to-Bucket with Presigned PUT (the baseline)
Use when: Small/medium objects (<100 MB), simple paths, minimal moving parts.
Idea: Your API signs the operation; the browser uploads directly to S3/R2. Your servers never proxy bytes.
// Node/Express: presign S3 PUT (AWS SDK v3)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: "us-east-1" });
app.post("/sign", async (req, res) => {
const { key, type } = req.body;
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: process.env.BUCKET, Key: key, ContentType: type }),
{ expiresIn: 60 }
);
res.json({ url });
});
Why it scales: Your compute stays almost idle; storage handles the heavy lifting. Add object-level lifecycle rules and request payer tags to keep costs tidy.
2) Presigned POST with enforced metadata
Use when: You need strict server-side validation of fields (size, type) without custom middleware.
Idea: Generate a form with constraints baked into the signature. The browser submits straight to the bucket.
// S3 POST policy fields (TypeScript)
const policy = {
expiration: new Date(Date.now() + 60_000).toISOString(),
conditions: [
{ bucket: process.env.BUCKET },
["starts-with", "$Content-Type", "image/"],
["content-length-range", 0, 10_000_000],
["starts-with", "$key", "uploads/"]
]
};
// Sign the policy; return url + fields to the client
Why it scales: Validation happens at the storage edge; you get fewer garbage writes.
3) Multipart Upload for large files (S3 & R2)
Use when: Files >100 MB, unreliable networks, or you need parallelism.
Idea: Split the object into parts; upload in parallel; complete the upload with a manifest of ETags.
// Browser: parallel multipart with S3 presigned URLs (pseudo)
const { uploadId, partUrls } = await fetch("/init-multipart?key=video.mp4").then(r => r.json());
const etags = await Promise.all(partUrls.map((u, i) => fetch(u, { method:"PUT", body: chunk(i) })
.then(r => ({ ETag: r.headers.get("ETag"), PartNumber: i+1 }))));
await fetch("/complete-multipart", { method:"POST", body: JSON.stringify({ uploadId, etags }) });
Why it scales: Retries are cheap (re-send a part), throughput is high, and memory use stays reasonable. On R2, pair with Workers for edge-close control.
4) Resumable Uploads (TUS or your own part ledger)
Use when: Mobile networks, flaky Wi-Fi, uploads that must survive tab or process crashes.
Idea: Keep a small server-side ledger (key → uploaded parts/offset). The client resumes from the last good offset instead of starting over.
Implementation sketch: Use a KV/Redis to track {key, uploadId, nextPart}
. Expose /resume
to query progress. The browser probes, then continues at nextPart
. UX feels magically robust.
5) Edge-Accelerated Ingest (R2 + Workers, or S3 + CloudFront)
Use when: Your users are far from your origin, or p95 is dominated by distance.
Idea: Move the first hop closer to the user. For R2, terminate at a Worker in the user's region and stream to the bucket. For S3, terminate via CloudFront or an EC2 ALB close to the user.
// Cloudflare Worker: receive chunks, pipe to R2
export default {
async fetch(req, env) {
const key = new URL(req.url).searchParams.get("key");
const obj = await env.R2_BUCKET.put(key, req.body, { httpMetadata: { contentType: req.headers.get("content-type") }});
return new Response(JSON.stringify({ ok: true, size: obj.size }), { headers: { "content-type":"application/json" }});
}
}
Why it scales: Shorter RTTs, fewer mid-path failures, and lower variance at the 95th–99th percentiles.
6) Zero-Trust Upload Gateway (signed URL broker)
Use when: You must never expose bucket write permissions directly, or you want per-user, per-file scoping.
Idea: Your gateway issues one-time, short-lived signed URLs scoped to {userId, key, size, mime}
. Every URL is auditable, ephemeral, and minimal in scope.
Security notes:
- Include a server-issued
x-amz-meta-userid
/custom metadata for traceability. - Enforce tight expirations (≤60s).
- Rate-limit the broker per account.
7) Post-Write Processing Pipeline (antivirus, thumbnails, transcodes)
Use when: You need to "bless" an object before it becomes public or searchable.
Idea: Upload to a quarantine prefix. Emit an event (S3 Event / R2 Object Created). A processing worker scans/transcodes and moves the final object to a public prefix with immutable cache headers.
Operational tips:
- Treat the move (or copy+delete) as the publish step.
- Attach a status document in DynamoDB/Firestore so the app can display "Processing…".
8) Customer-Owned Encryption & Compliance (SSE-KMS, legal holds)
Use when: Regulated data, deletion windows, or BYOK.
Idea: Use S3 SSE-KMS with per-tenant keys; on R2, use encryption at rest plus application-level envelope encryption when needed. Keep object tags for retention, legal hold, and data residency.
Why it scales: Crypto is delegated to managed services; you enforce policy with tags and IAM boundaries instead of custom code.
9) Cost-Aware Multi-Region Strategy (hot/cold + replication)
Use when: You need durability and fast reads across continents — without doubling your bill.
Idea:
- Ingest to the user's nearest region (edge or region).
- Replicate asynchronously to a cheaper cold region for durability and batch processing.
- Serve downloads from the closest edge cache; keep origin egress minimal.
Pragmatic knobs: Lifecycle rules for big videos, intelligent tiering, and request-payer buckets for internal pipelines.
Practical guardrails (that save weeks later)
- Chunk size: 5–64 MB works well for multipart; larger parts reduce overhead but raise retry cost.
- Key design: Use deterministic keys:
tenantId/yyyy/mm/dd/uuid.ext
. Prevents hot partitions and eases cleanup. - Content validation: Don't trust
Content-Type
from the client—sniff server-side in the processor stage. - Idempotency: Add an
uploadToken
oridem
key when initializing; safe retries reduce support tickets. - Observability: Log
key, userId, size, region, p95, retries
. Uploads are "write once, debug forever" unless you keep the breadcrumbs.
Tiny end-to-end example (presigned multipart, TypeScript)
Server (init & complete):
// Pseudo: AWS SDK v3
import { S3Client, CreateMultipartUploadCommand, CompleteMultipartUploadCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: "us-east-1" });
export async function initMultipart(key: string, contentType: string) {
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket: process.env.BUCKET, Key: key, ContentType: contentType
}));
// Generate presigned URLs for parts 1..N in a separate step to control concurrency
return { uploadId: UploadId };
}
export async function completeMultipart(key: string, uploadId: string, parts: { ETag:string, PartNumber:number }[]) {
await s3.send(new CompleteMultipartUploadCommand({
Bucket: process.env.BUCKET, Key: key, UploadId: uploadId,
MultipartUpload: { Parts: parts.sort((a,b)=>a.PartNumber-b.PartNumber) }
}));
}
Client (resumable notion):
// Browser (simplified)
const CHUNK = 8 * 1024 * 1024;
async function sendFile(file: File) {
const { uploadId } = await post("/init", { key: file.name, type: file.type });
let part = 1, offset = 0, etags: any[] = [];
while (offset < file.size) {
const slice = file.slice(offset, offset + CHUNK);
const url = await post("/sign-part", { key: file.name, uploadId, part }); // short-lived
const resp = await fetch(url, { method: "PUT", body: slice });
etags.push({ PartNumber: part, ETag: resp.headers.get("ETag")! });
offset += CHUNK; part++;
}
await post("/complete", { key: file.name, uploadId, etags });
}
Why this combo performs: Direct-to-bucket removes server bottlenecks; multipart enables retries; short-lived signed URLs maintain zero-trust; you can add edge termination later without changing the contract.
Choosing the right design (quick map)
- Under 100 MB, simple rules: Presigned PUT.
- Strict browser-side validation: POST policy.
- >100 MB, unreliable networks: Multipart with resume.
- Global audience: Edge-accelerated ingest.
- Sensitive data: Zero-trust gateway + KMS.
- Processing required: Quarantine → pipeline → publish.
- Cost pressure: Tiering + async replication; keep egress at the edge.
You might be wondering, "Can I mix them?" Absolutely. Many teams run presigned PUT for small stuff, multipart for the big hitters, and route VIPs to edge ingest.
Conclusion
Uploads aren't a one-size feature; they're a portfolio. Start with direct-to-bucket presigned PUTs, graduate to multipart + resume for large files, and pull in edge and zero-trust as your audience grows. Keep the contract stable (signed URLs + metadata), and you can swap in new infrastructure without breaking client code.
CTA: What's your biggest upload pain — mobile resumes, p95 variance, or virus scanning? Drop a comment and I'll share a focused recipe for your stack.