File upload and storage, where the bytes never touch your servers
Large files through an application server is the design most systems start with and the first one they replace. The better design sends bytes straight to storage and keeps only the record.
The brief
Users upload files — documents, images, videos up to several gigabytes — and share them with other users.
Uploads happen on unreliable mobile connections, and some files must be scanned before anyone can download them.
Requirements
Functional
- Upload files up to 5 GB, resuming after a dropped connection
- Download files, including by users the owner shared them with
- Scan uploaded files and block downloads of anything unsafe
- Delete files, and actually remove the bytes
Non-functional
- An upload interrupted at 90% must not start again from zero
- Nobody downloads a file they are not allowed to, including by guessing a URL
- Storage cost grows with use, so rarely read files should cost less
Back-of-envelope
Assume
- 10 million users, 1 million uploads a day
- Average file 20 MB; 1% of uploads are videos around 1 GB
- Each file is downloaded 5 times in its first week, and rarely afterwards
- Files are kept until the owner deletes them
Therefore
- Ingest: 1e6 × 20 MB = 20 TB a day ≈ 230 MB/s on average. Proxied through application servers, that is a fleet sized by bandwidth doing no application work.
- Storage: ≈7.3 PB a year of new data before replication. Most of it is not read after the first week — which is what makes tiering the biggest cost lever.
- Downloads: 5 × 20 TB = 100 TB a week of egress for new files. Egress is often priced higher than storage, so where downloads are served from matters more than where files are kept.
- A 1 GB upload at 5 Mbit/s takes about 27 minutes. The chance of a mobile connection surviving 27 minutes uninterrupted is low, which is why resumable upload is a requirement rather than a feature.
The average file size hides the distribution. The 1% of uploads that are videos are about half of all bytes, so a change in how videos are handled moves every number above.
The interface
What is stored
file_id · owner_id · name · size · content_type · storage_key · status (UPLOADING | SCANNING | READY | BLOCKED | DELETED) · created_atstatus is what download checks; the storage key is random, never derived from the file name or owner, so knowing one file's key reveals nothing about another's.
file_id · grantee_id · permission · PRIMARY KEY (file_id, grantee_id)Serves the one question download asks — may this user read this file — as a primary-key lookup.
bucket/storage_key → bytes, with lifecycle rules by ageBytes live only here. Lifecycle rules move objects to cheaper storage classes after a period without reads, and abort multipart uploads that were never completed.
The design
The decisions
Each of these could go the other way. The choice, the reason, and what it costs — a design that lists only what it chose teaches the choice; one that lists what it gave up teaches the judgement.
What breaks first
In order. Each names what you would actually observe, and each fix carries its cost.
When something fails
Scaling it
Each step is triggered by a number, not a feeling — and carries what it costs.
What gets probed
The design is the easy half. These are where the conversation goes, and each has a defensible answer above.
- A 4 GB upload fails at 90%. What does the client do, and how much is sent again?
- Someone copies a download link into a public forum. What happens an hour later?
- Why is the storage key random instead of owner/filename?
- Where does the cost of this system actually go, and which single change reduces it most?