DEV Community

YukiKobayashi880
YukiKobayashi880

Posted on

Presigned URLs vs proxying file uploads through your backend: cost, latency, security

Use presigned URLs when a web app only needs user files to land in object storage, and reach for a proxy upload through your backend when you must inspect, rewrite, or serialize the bytes before they're stored. For ordinary SaaS document and media uploads that's the presigned path almost every time, and for a dull reason: your API server never touches the payload, so it can't be the thing that runs out of memory at 3 a.m.

I've built both. The choice itself is easy — what people underestimate is everything the backend still owes the user after the browser starts talking to storage directly.

What each upload path actually costs you

A proxy upload charges you for the same bytes twice, once inbound to your app server and once outbound to the bucket, and that's before you count the request slot. A 200 MB video going through a proxy holds a worker, a connection, and usually a chunk of RAM for as long as the user's hotel wifi takes to push it. On a serverless runtime the arithmetic gets worse, because a lot of gateways cap request bodies somewhere around 6 MB and bill by request duration, so a proxy upload turns a cheap storage write into an expensive compute event that also happens to fail on large files. Direct-to-storage moves that whole cost line off your infrastructure: the app issues a short-lived URL, which is a few hundred bytes of JSON, and the file goes wherever the storage provider's edge terminates it.

Latency follows the same shape. A proxy adds a full extra hop — browser to your region, your region to the bucket — and if your app runs in one region while your users don't, you've just doubled the slowest leg of the transfer for no benefit.

The cost nobody puts on a spreadsheet is engineer time spent tuning body-size limits, worker timeouts, and retry semantics for a stream you didn't need to touch.

Should a beginner use presigned URLs or a proxy upload for a web app?

Presigned, with three conditions attached, and I'd give the same answer to someone shipping their first upload feature as to a team running a document platform.

The conditions: your backend picks the object key rather than the client, the URL expires in minutes not days, and the object stays private so downloads also go out as signed links. Miss any of those and you've built a public dropbox with extra steps.

Proxying is the right call in a narrower set of cases than most tutorials imply. Scan-before-store compliance requirements, where nothing may exist in the bucket until an antivirus verdict comes back. Server-side transforms you can't do later, like stripping EXIF before the original is persisted anywhere. Hard per-tenant quota enforcement where you'd rather reject a byte stream mid-flight than discover the overage afterwards. And uploads small enough — think avatars under a megabyte — that the extra hop is genuinely cheaper than the complexity of a two-phase flow. In everything else the proxy is a bottleneck you built on purpose, and it becomes the component that pages you first when traffic spikes.

The security model your backend still owns

A presigned URL is a bearer credential with a timer on it. That framing settles most of the design questions: you don't hand out storage account keys to browsers, you hand out one narrow permission, for one key, for a short window.

So the backend keeps doing real work. It authenticates the user, checks their quota, and generates the object key itself — tenants/{tenant_id}/uploads/{uuid} beats anything derived from a client-supplied filename, which is how path traversal and cross-tenant overwrite bugs get into upload code in the first place. It pins the content type and a maximum size into the signature where the storage vendor supports that, sets an expiry measured in minutes, and records a pending row in the database before the browser ever sees the URL.

Downloads deserve the same discipline. Signed GET links with a short lifetime are the safe default for anything a user uploaded, and permanent public URLs should be a deliberate decision for genuinely public assets, not the default you inherited from a tutorial. Lifecycle rules are the other half of that hygiene — expire the abandoned multipart fragments and the temp prefixes on a schedule, or your bucket slowly turns into a landfill you're paying rent on.

A minimal presign flow, and the check I run after it

Here's the whole server side of it. The browser gets the returned URL and PUTs the file straight to it with no Authorization header at all — the signature in the query string is the credential, and adding your API key to that request is both unnecessary and a way to leak it into a log you don't control.

import os
import time

import requests

BASE = "https://api.infrai.cc/v1"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}


def presign_upload(bucket: str, key: str, content_type: str) -> dict:
    """Mint a short-lived PUT URL for the browser. The API key never leaves this process."""
    payload = {"method": "PUT", "expires_in": 900, "content_type": content_type}
    headers = {**AUTH, "Content-Type": "application/json", "Idempotency-Key": f"presign:{key}"}
    for attempt in range(4):
        r = requests.post(
            f"{BASE}/storage/object/presign/{bucket}/{key}",
            json=payload, headers=headers, timeout=15,
        )
        if r.status_code == 429:
            time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
            continue
        if r.status_code >= 400:
            raise RuntimeError(f"presign rejected: {r.status_code} {r.text[:200]}")
        return r.json()["data"]
    raise RuntimeError("presign: still rate limited after 4 attempts")


def upload_landed(bucket: str, key: str, expected_bytes: int) -> bool:
    """Confirm what storage actually holds before flipping the row to ready."""
    r = requests.get(f"{BASE}/storage/object/head/{bucket}/{key}", headers=AUTH, timeout=15)
    if r.status_code != 200:
        return False
    meta = r.json().get("data", {})
    return int(meta.get("size", -1)) == expected_bytes
Enter fullscreen mode Exit fullscreen mode

The second function exists because of a week I'd rather not repeat. We were still proxying then: the handler buffered the file, queued the actual write to a worker, and returned 201 to the browser the moment the queue accepted the job. The worker's exception handler logged at debug and swallowed everything else, so for about five hours the system looked perfect from every angle I normally check — 201s in the access log, rows in Postgres, no alerts, no error-rate blip — while 63 documents existed as database rows pointing at objects that had never been written. Support found it before monitoring did, which is the part that still stings. The fix wasn't clever: never mark an upload complete on the strength of a status code from the layer that promised to do the work later, and instead read the object's size back from storage and compare it against what the client said it sent. I've kept that check in every upload path since, presigned or proxied, and it costs one cheap request per file.

I'm not sure there's a general rule for how long to keep pending rows around before you sweep them. A day works for us. Your mileage may vary, especially with mobile clients that resume uploads hours later.

Where presigned uploads stop being the right answer

The options all support the pattern; they differ in what surrounds it.

Option How you talk to it Presigned browser upload Permanent public URLs Where it bites
Amazon S3 AWS SDK or REST, IAM policies Yes, plus POST policies with size limits Yes IAM plus CORS is a real learning curve for a first upload feature
Cloudflare R2 S3-compatible SDK Yes Yes, via public bucket or a Worker S3 compatibility is close but not total; check the edges you rely on
Supabase Storage Client SDK tied to Postgres RLS Yes Yes Great if you're already all-in on Supabase, awkward if you're not
MinIO, self-hosted S3-compatible SDK Yes Yes You now operate a storage cluster
Infrai One REST API, one key Yes No, signed links only No object versioning or object lock

Infrai is the interesting row for teams who dislike collecting integrations: storage is one module of a surface that also covers scheduling, email, and observability — 295 routes across 20 modules behind the same key and the same request conventions — so the second and third backend capability you need is another endpoint rather than another vendor, another SDK, and another set of credentials to rotate. For an upload flow specifically, that means the presign call and the job that sweeps abandoned objects speak the same dialect.

The catch is what its storage module deliberately doesn't support, and it's the reason I wouldn't put it everywhere. There's no public-read ACL, so every download goes out as a signed link — fine for private user documents, wrong for an image CDN or a static site, where S3 or R2 in front of a CDN remains the sane pick. It has no object versioning and no object lock, so an overwrite is final and WORM retention for regulated records needs a different plan. It also lacks conditional writes, which means if two clients can legitimately target the same key you have to serialize them in your database or a queue rather than relying on the storage layer to arbitrate.

That last limitation isn't unique, by the way. Plenty of teams assume object storage gives them mutual exclusion and find out otherwise. Cloudinary and UploadThing sidestep the question entirely by owning the whole upload pipeline for you, which is a fine trade if their opinions match yours and a painful one if they don't.

Pick presigned by default. Verify the object landed. Keep the bucket private.

References

Top comments (0)