TL;DR — Use presigned URLs by default. They're faster, cheaper, and avoid the request body size cliff. Use a server proxy only when you need synchronous virus scanning, server-side transformation before storage, or when your compliance team won't sign off on direct-to-storage uploads. Most teams who think they need a proxy actually don't.
The presigned URLs vs server proxy decision shows up in every greenfield project that handles file uploads. Both work. Both ship to production. But they have very different cost curves, very different failure modes, and very different stories when a file is 4 GB or when the user is on a flaky connection from Indonesia. This post is the comparison we wished we had when designing UploadKit.
What each pattern actually does
Presigned URL pattern:
- Client tells your server "I want to upload
cat.jpg, 2 MB, image/jpeg." - Server validates the request, generates a short-lived signed URL with the storage provider (S3, R2, GCS).
- Client uploads the file directly to the storage URL.
- Optionally, storage notifies your server via webhook when the upload completes.
Server proxy pattern:
- Client uploads the file to your server (multipart/form-data or chunked).
- Server receives the bytes, validates, optionally transforms, then writes to storage.
- Server returns the storage URL to the client.
Both patterns satisfy the same product requirement. The differences are in everything else.
The trade-off matrix
| Concern | Presigned URLs | Server proxy |
|---|---|---|
| Latency | Lower (1 round trip to storage) | Higher (2 round trips) |
| Egress cost | None — bytes never hit your server | You pay ingress + egress |
| Server CPU | Negligible | High (parsing multipart) |
| Request body limits | N/A — limited only by storage | Capped by Vercel/Cloudflare/etc. |
| Virus scanning | Async (after upload, via webhook) | Sync (before storage write) |
| Image transformation | Async or via CDN | Sync, before storage |
| Audit trail | Webhook-driven, eventually consist. | Synchronous, immediate |
| Observability | Harder — uploads bypass your logs | Easier — every byte hits your app |
| Network failure UX | Client retries against storage | Client retries against your app |
| CORS configuration | Required on the bucket | N/A |
| Auth complexity | Sign with caller identity | Standard request auth |
| Multipart for >5 GB | Native S3 multipart | You implement chunking yourself |
Latency: presigned URLs win, by a lot
A server proxy upload looks like this:
Client ──[2MB file]──> Your server ──[2MB file]──> Storage
──[200 OK]──<
<──[200 OK]──The file traverses two networks. If your server is in us-east-1 and the user is in Tokyo, the file makes a 200 ms round trip just to reach your server, then another 50 ms to reach S3 in the same region. Total time: ~3 seconds for a 2 MB file on a 10 Mbps connection.
A presigned URL upload looks like this:
Client ──[req URL]──> Your server
<──[URL]────
──[2MB file]──> Storage
<──[200 OK]──The file hits storage directly. If you're using R2 (anycast, no regions) or S3 with Transfer Acceleration, the user uploads to the nearest edge. Same scenario: ~1.5 seconds.
For small files this is a 1.5 second difference. For a 4 GB video upload from a mobile network, the proxy pattern can take 2x longer than presigned URLs because the bytes traverse twice.
Cost: presigned URLs win again
Server proxy uploads are double-billed:
- Ingress to your application server (free on AWS, but burns CPU/memory).
- Egress from your application server to S3 (free if same region).
- If your app server is on Vercel/Render/Fly, you may pay for the bandwidth.
The bigger cost is CPU. Parsing multipart/form-data for a 100 MB file in Node.js with busboy takes meaningful CPU time. At scale, a single Vercel function can serve maybe 10 concurrent 100 MB uploads before it's saturated. The same function serving presigned URL requests handles 1000+ requests per second because the response is a 200-byte JSON object.
We benchmarked this for UploadKit early on. Switching from a proxy POC to presigned URLs reduced our compute bill by ~85% and let us serve the same load on a single Vercel hobby plan instead of a Pro plan with provisioned concurrency.
Request body limits: the hidden cliff
Most serverless platforms cap request body size:
- Vercel: 4.5 MB on Hobby, 250 MB on Pro/Enterprise (Edge Functions are stricter at 4 MB).
- Cloudflare Workers: 100 MB.
- AWS Lambda: 6 MB synchronous, 256 KB async.
- Render/Fly: configurable, but high-memory plans get expensive.
A server proxy hits these limits hard. The user's 500 MB video upload returns a 413 Payload Too Large and they have no idea what to do. You can work around it with chunked uploads, but now you've reinvented multipart S3 upload — badly.
Presigned URLs have no body limit on your server because the file never touches your server. The storage provider's limit (5 TB on S3, 5 TB on R2, with multipart) is the only ceiling.
Where server proxy actually wins
Be honest about your requirements before defaulting to presigned URLs. Server proxy is the right call when:
Synchronous virus scanning is a hard requirement
Some compliance regimes (HIPAA, certain SOC 2 controls, government work) require that a file is scanned before it's stored. With presigned URLs, the file lands in S3 and you scan it asynchronously via Lambda or an event trigger — there's a window where the file is in the bucket but not scanned.
If your auditor won't accept "scanned within 30 seconds of upload," you need a proxy that runs ClamAV (or a paid scanner like VirusTotal) during the request and rejects the upload before storage write.
Server-side transformation before storage
Some workflows want the canonical version stored, not the user's upload. Examples:
- Strip EXIF data from photos before storage.
- Convert HEIC to JPEG before storage.
- Re-encode video to a known codec.
- Watermark images.
You can do all of this with presigned URLs and a worker that processes after upload, but the unprocessed file lives in storage briefly. If that's unacceptable (e.g., you legally cannot store the original), proxy.
Strict observability requirements
Some organizations want every byte uploaded to flow through their application logs. With presigned URLs, the upload bypasses your application — you only see the metadata request. Webhooks fill the gap, but they're eventually consistent.
If your security team requires synchronous "this user uploaded this file" log lines, proxy.
Very small files where the round trip dominates
For files under ~10 KB, the cost of the presigned URL handshake (one full HTTP request and response) is comparable to just sending the bytes. If you're uploading thousands of tiny files (e.g., user avatars from a bulk import), batching them through a proxy can be faster.
This is a niche optimization. For a single avatar upload, presigned URLs are still fine.
What about the hybrid?
A common pattern: presigned URLs for the upload, then a webhook to your server that triggers virus scanning and transformation. The user sees a fast upload; you get the security and processing.
This is what we do at UploadKit. Files land in R2 via presigned PUT, then a Cloudflare Worker subscribes to the bucket events and runs scanning + thumbnail generation. The user's upload completes in ~2 seconds; the file is "verified" within ~5 seconds.
The only thing this doesn't solve is the "file in storage briefly before it's safe" problem. For most products, the answer is to not expose the file's URL until the webhook confirms it's safe. The file exists, but nothing serves it.
Decision framework
Ask three questions:
- Are uploads larger than your platform's body limit? If yes, presigned URLs (or you're rebuilding multipart).
- Does compliance require synchronous scanning or transformation? If yes, proxy.
- Is observability of every byte a hard requirement? If yes, proxy.
If all three are no — and for most products they are — use presigned URLs. They're cheaper, faster, and don't expose you to the request body cliff.
What this means for you
Default to presigned URLs. They scale, they're cheap, they work on every serverless platform, and they give the user a faster upload. The patterns we wrote about in How to upload files to Cloudflare R2 in Next.js implement the presigned URL pattern end-to-end if you want a working reference.
If you do need a proxy for compliance reasons, accept the cost. Don't try to make presigned URLs "synchronous-scan-equivalent" — you'll end up with a broken hybrid that has the worst of both worlds.
UploadKit defaults to presigned URLs and supports webhook-based scanning. If you have a hard proxy requirement, our self-hosted SDK supports a server proxy mode — check the docs for the configuration.
Related reading:
External references: