A user uploads a 400MB video. If that goes through your Worker, you are paying for the transfer twice, holding it in memory you do not have, and racing a request timeout you will lose.
pithy add media hands the client a direct-upload URL instead. The bytes go from the user’s device to storage without passing through your code. What your Worker does is decide whether the upload is allowed and record that it happened.
One record, whichever backend
Images, video, audio and documents want different homes. Cloudflare Images does variants and transformations. Stream does encoding and playback. R2 holds everything else.
Your config picks the backend per type. What your application code sees is one media record with an owner, a kind, a state, and an id — regardless of which service the bytes ended up in.
That matters more than it sounds. Without it, “show me everything this user uploaded” is three queries against three services with three shapes, joined in application code. With it, it is a query against a table in your own D1 that happens to reference three services.
The migration-order bug worth telling you about
A small thing that says something about how this is put together.
Media records name an owner, and owners are users, so the media tables have to exist after the auth tables. Migrations declare an order for exactly this reason.
Media was originally given order 300. So was auth. Any project composing both — which is every project that stores files against an identity — got this on pithy migrate:
duplicate migration order 300 in database "app"Not a subtle failure. A loud one, at the earliest possible moment, naming both the number and the database. Media moved to 350 and the comment explaining why sits next to the constant.
That is the behavior you want from ordering conflicts: caught at assembly, named precisely, impossible to half-apply.
Enrichment is a Workflow, not a request
You want alt text on images, transcripts on audio, and extracted text from documents. All three are genuinely useful, and all three are slow and unreliable in the same ways.
An AI model takes seconds. Sometimes tens of seconds. Sometimes it fails, and the right response is to try again in a minute rather than to fail the upload.
None of that belongs in a request. So enrichment runs as a Workflow: the upload completes and the record is written immediately, and the enrichment attaches when it is ready. The user’s upload does not wait for a caption, and a model outage does not become a broken upload button.
It is opt-in per type, because the cost is yours and you should choose to spend it.
What your Worker actually decides
Handing out a direct-upload URL is an authorization decision, and it is the one thing that cannot be delegated.
Your Worker answers: is this caller allowed to upload, what kind, how large, and against which owner. Then it mints a URL scoped to that answer. The client uploads. A callback confirms, and the record moves to a state your queries can trust.
So the expensive part is not in your code and the decision is.
sequenceDiagram
actor C as Your client
participant W as Your Worker
participant S as R2 · Images · Stream
participant DB as Your D1
C->>W: May I upload this?
W->>W: Allowed? What kind? How large? Whose?
W-->>C: A URL scoped to that answer
C->>S: The bytes
Note over W: They never pass through here
S->>W: Callback
W->>DB: The record moves to a state your queries can trust
What it does not do
It does not moderate content. If you need to know whether an image is acceptable before you show it to other people, that is a policy question and a different tool, and you should not infer it from the presence of alt text.
It does not manage rights or licensing. The record says who uploaded a file, not who is allowed to use it.
And it does not make your storage bill someone else’s problem. The bytes are in your account, which is the point — but so is the invoice.
Why it is in the kit
Because “upload a file” is one of those features that looks like an afternoon and is not.
The afternoon version posts the file to your API. It works in testing, where files are small and connections are local. It falls over the first time somebody uploads from a train.
The version that actually works needs direct-upload URLs, an authorization step separated from the transfer, a record that survives the client disappearing mid-upload, a state machine for uploads that never complete, and somewhere sensible to put the slow enrichment work. That is a week, and it is the same week for every project.