A user picks a 40 MB video, your API accepts the body, holds it in memory or a temp file, then re-uploads it to S3. The upload works in development. In production it is the endpoint that eats your RAM, sits open for the duration of the client's hotel wifi, and times out exactly on the largest files users send. The fix is not a bigger server. It is getting your server out of the data path entirely.
The constraint
The API's job is to decide whether an upload is allowed: authenticated user, right file type, sane size, correct destination key. Carrying the bytes is a different job with different economics, and your API instance is the worst place in the system to do it.
The options
Proxy through the API. POST /upload streams to the server, the server streams to S3. Simple to reason about, easy to inspect the bytes mid-flight, and every byte costs you twice: inbound bandwidth, outbound bandwidth, and a held connection for the full duration. Your p99 latency is now a function of your slowest user's upload speed.
Presigned PUT URL. The client asks your API for permission; the API signs a URL for one PUT to one key in one bucket and returns it. The client PUTs the bytes straight to S3. The URL inherits the signer's permissions and stays usable until it expires, with SigV4 capping expiry at 604,800 seconds, seven days, because the signing key itself is only valid that long.
Presigned POST policy. The browser-native variant: the API returns a small JSON policy plus a signature, the client POSTs a multipart form straight to S3. The policy is a base64 JSON document of conditions, and AWS requires every form field except the signature, the file itself, policy, and x-ignore- prefixed names to appear in those conditions. The useful ones for bounding an upload: starts-with on the key, eq on content type, and content-length-range on size.
The mechanism
The POST policy is the artifact worth understanding, because it is a contract S3 enforces on your behalf. The demo below builds one and evaluates candidate uploads against it, the same check S3 runs. Run it with node post-policy.mjs:
// a presigned post policy is a contract the upload cannot exceed.
// the server signs it once; s3 enforces it on every upload.
const policy = {
expiration: "2026-11-12T12:00:00.000Z",
conditions: [
{ bucket: "uploads.acme.example" },
["starts-with", "$key", "u_17/"],
["content-length-range", 1, 10 * 1024 * 1024],
["eq", "$Content-Type", "image/jpeg"],
],
};
function evaluate(p, form) {
// s3 also checks p.expiration against request time; form
// entries model the upload, content-length stands in for size
for (const c of p.conditions) {
if (!Array.isArray(c)) {
const [k, v] = Object.entries(c)[0];
if (form[k.slice(1)] !== v && form[k] !== v)
return `rejected: ${JSON.stringify(c)}`;
} else if (c[0] === "starts-with") {
if (!form[c[1].slice(1)].startsWith(c[2]))
return `rejected: starts-with ${c[2]}`;
} else if (c[0] === "eq") {
if (form[c[1].slice(1)] !== c[2])
return `rejected: eq ${c[1]} ${c[2]}`;
} else if (c[0] === "content-length-range") {
const size = form["content-length"];
if (size < c[1] || size > c[2])
return `rejected: content-length-range ${c[1]}..${c[2]}`;
}
}
return "accepted";
}
const attempts = [
{ bucket: "uploads.acme.example", key: "u_17/avatar.jpg", "content-length": 2_100_000, "Content-Type": "image/jpeg" },
{ bucket: "uploads.acme.example", key: "u_17/raw.dng", "content-length": 38_000_000, "Content-Type": "image/x-adobe-dng" },
{ bucket: "uploads.acme.example", key: "u_99/avatar.jpg", "content-length": 900_000, "Content-Type": "image/jpeg" },
{ bucket: "uploads.acme.example", key: "u_17/report.pdf", "content-length": 4_000_000, "Content-Type": "application/pdf" },
];
for (const a of attempts)
console.log(`${a.key} ${(a["content-length"] / 1e6).toFixed(1)}MB ${a["Content-Type"]} -> ${evaluate(policy, a)}`);
u_17/avatar.jpg 2.1MB image/jpeg -> accepted
u_17/raw.dng 38.0MB image/x-adobe-dng -> rejected: content-length-range 1..10485760
u_99/avatar.jpg 0.9MB image/jpeg -> rejected: starts-with u_17/
u_17/report.pdf 4.0MB application/pdf -> rejected: eq $Content-Type image/jpeg
Three of four attempts never reach a byte of your infrastructure, and the rejections happen at S3's edge, not your rate limiter. The oversize file dies on the size bound, the foreign key prefix on starts-with, the PDF on the content type.
Where it bites
The first bite is that a presigned URL is a bearer instrument. AWS states you "can use the presigned URL multiple times, up to the expiration date and time," and that an existing object at the same key is replaced by the upload. Anyone who logs or forwards that URL can write to it until it lapses, so expiry should be minutes, not the seven-day maximum, and the key should be per-upload, not per-user.
The second bite is the browser. A presigned URL is still a cross-origin request, and S3 requires a CORS configuration on the bucket before browser JavaScript can PUT or POST to it. Cloudflare documents the failure mode that wastes hours: an expired presigned URL returns 403 ExpiredRequest without CORS headers, so client-side fetch cannot read the error body and reports a generic network failure. Your upload code cannot tell "expired" from "offline" unless you handle the opaque failure and mint a fresh URL.
The third bite is size. A single PUT tops out at 5 GB; past that, multipart is mandatory, and AWS advises it from 100 MB up anyway because a failed part retransmits alone. Multipart means presigning every part URL (10,000 parts max, 5 MiB to 5 GiB each, no minimum on the last part, 50 TB object ceiling), and every step of the operation must start before its URL expires. It also means a cleanup job: AWS notes an initiated multipart upload has no expiry: "you must explicitly complete or stop the multipart upload," and orphaned parts bill like stored objects until a lifecycle rule aborts them.
The honest cost of presigning: your API no longer sees the bytes. No mid-flight virus scan, no EXIF strip, no inspection of any kind until after the object lands. The standard fix is event-driven: S3 ObjectCreated triggers your scan after the fact, and the object stays quarantined by key prefix until it passes.
The numbers
- Presigned URL expiry: 1 second to 604,800 seconds (7 days) under SigV4; the S3 console caps itself at 12 hours, and its own download URLs live 300 seconds.
- Single PUT: 5 GB max. Multipart: required above 5 GB, recommended at 100 MB; 10,000 parts, 5 MiB-5 GiB per part, 50 TB object max (raised from 5 TB).
- R2 presigned URLs: same mechanism, same 1s-to-7d expiry, generated server-side with no communication with R2.
- POST policy:
content-length-range,starts-with, andeqconditions;success_action_statusaccepts 200, 201, or 204.
Our position
Presign by default, proxy on purpose. Default path for a mobile app: client requests a presign, API authenticates and mints a POST policy or PUT URL scoped to u_{user}/{uuid} with a size cap and content type, client uploads directly, and an ObjectCreated event kicks off whatever processing the file needs. Proxy only when the bytes themselves are the point of the check: inline antivirus scanning, server-side transcoding before the file may exist, or files small enough that the debate is academic. And treat every presigned URL as what it is: a capability, valid until expiry, reusable, and worth the same protection as the credentials in your API key is inside the APK.
The takeaway
scope: bucket + key prefix + type + size, minutes not days
abort initiated uploads via lifecycle rule or they bill
you pay bandwidth twice plus a held connection
urls 403 without cors headers, so retries must refresh
scan on objectcreated before the file is usable
This is the same upload shape we put behind the file flows in apps like CashTaq in our product engineering work. If your upload endpoint doubles as your bandwidth bill, send us the flow.
// sources
// next step
We work this way on client projects too.
Tell us what you are making. We reply with a plan, a timeline, and a price.
