The first version of file upload everybody writes takes the bytes through the server. It works until somebody uploads a 400 MB video, and then it works badly: your Worker has a CPU budget, a memory budget and a request duration, and none of them was designed to relay half a gigabyte.
@pithy-sh/storage never does that. An upload is a presigned PUT straight to your own R2 bucket — the bytes go from the browser to Cloudflare and your Worker sees a row, not a file. Past 100 MiB it is a resumable multipart upload instead, which is the same trade at a size where a dropped connection matters.
Downloads do stream through the Worker, and that is deliberate: a download is the moment authorization is decided, and a presigned GET is a URL anybody who has it can use.
What it gives you
- An owner and a quota. Objects belong to an authenticated user, and a byte quota is enforced per owner rather than per bucket.
- Logical paths that are yours, keys that are not. You supply a path, and it is stored, indexed and listable. The R2 key itself is server-derived and opaque, so a client can never name an object or guess the one beside it.
- Revocable share links. A short URL, at its own short path because the whole thing gets pasted into chat. Revoking one is a row you delete.
- Range, ETag and
Content-Dispositionstreaming for the ordinary case, and server-side copy for duplication that never leaves the account. - A daily sweep that reconciles orphaned rows and orphaned objects in both directions — a row with no bytes behind it, and bytes with no row in front.
Uploaded bytes are treated as untrusted on the way out
This is the part worth reading even if you skip the rest.
A user’s file served from your origin is a script running on your origin, if the browser can be talked into executing it. So every object response carries nosniff and a locked-down content-security policy, and an active type — HTML, SVG, anything ending +xml, script — is served as an attachment whatever was stored. Not blocked at upload, which is a different and worse trade; served in a way a browser will not execute.
That, plus the opaque keys, is why a user’s file cannot execute on your origin and cannot be found by walking a URL.
What it deliberately does not do
It takes no position on what the bytes are. No transcoding, no thumbnails, no format sniffing, no AI. That is media — which presigns through this package’s object-store seam, against its own bucket and its own credential name, and inherits none of these tables or routes for doing so.
It does not do public CDN hosting. Objects are private by default and served by your Worker. A file everybody may read is a legitimate thing to want and R2’s own public bucket is the better tool for it.
It does not version objects. A write to the same logical path replaces what was there.
The two thresholds worth knowing
| Setting | Default | Why it matters |
|---|---|---|
multipartThresholdBytes | 100 MiB | Above this, an upload is split into resumable parts. It cannot exceed R2’s single-PUT ceiling |
partSizeBytes | 64 MiB | Bytes per part. R2 allows at most 10,000 parts, so this is what fixes the largest object you can store |
Those two numbers multiply into your maximum object size. Raising the part size raises the ceiling and costs a larger retry when one part fails.
defaultVisibility is private, so a forgotten field can never publish a file. pendingTtlSeconds is one day — long enough for a slow multipart upload to finish, short enough that an abandoned one gives its quota reservation back.
When you would reach for it
When your users upload files that belong to them: an avatar, an attachment, a document, an export. Anything with an owner, a size worth counting, and a reason somebody else should not read it.
Not for your own static assets — those ship with the Worker. Not for a public image CDN. And not for images, video or audio you intend to process, where media is the capability that already knows what to do with them.
What it needs
It requires secrets: the R2 credentials are read through the registry, so storage will not compose without it.
auth is optional and effectively required. Every owner-scoped route reads the authenticated user, so with no auth composed those routes deny.
pithy add storage writes bindings and touches no Cloudflare account. The bucket, the credentials secret and the sweep worker arrive with pithy storage provision, which needs an R2 S3 access-key pair you make by hand — Cloudflare exposes no API for minting one.