This is the Dropbox or Google Drive question. You save a file on your laptop, and a few seconds later it is on your phone and on your friend's computer. It sounds like "upload a file, download a file". The real work is in what happens when the file is big, when two people edit it at once, and when 50 million users do this all day.
1. Problem Statement
Design a service where a user keeps a folder of files in the cloud, and every device they own shows the same folder, kept up to date automatically. Users can share folders with other people, see old versions of a file, and recover from mistakes.
A good picture is a shared notice board in an office. Anyone can pin a new sheet or replace one, and everyone who walks by sees the latest. The hard part is when two people replace the same sheet at the same moment, and when the sheet is a 20 GB video.
2. Questions to Ask First
| Question | Assumption I will make |
|---|---|
| How many users? | 50 million registered, 10 million active each day |
| How many devices per user? | 2 on average (laptop and phone) |
| How much data per user? | About 4 GB on average |
| How many files per user? | About 1,000 |
| How big can one file be? | Up to 50 GB. Most are small, a few MB |
| How often do files change? | About 20 file changes per active user per day |
| Should sync feel instant? | Yes. A change should show on other devices in a few seconds |
| Do we need real time co-editing of documents? | No. That is Google Docs, which is a different problem. We sync whole files |
3. Requirements
3.1 Functional Requirements
| Requirement | Detail |
|---|---|
| Upload and download | Any file up to 50 GB, with resume after a dropped connection |
| Sync | A change on one device appears on the user's other devices |
| Efficient updates | Editing a small part of a big file only sends the changed part |
| Folders | Create, rename, move, delete. Moving a folder must be cheap |
| Versions | See and restore older versions of a file, for 30 days |
| Sharing | Share a folder with other users as viewer or editor, or share a file by link |
| Conflicts | If two devices change the same file, nobody loses work |
| Offline | Edits made offline sync when the device comes back |
3.2 Non Functional Requirements
- Never lose a file. This is the one promise the product makes. Data must survive disk, machine and even data centre failures.
- Sync should feel quick. Under 5 seconds from save to appearing on another online device is a good target.
- Bytes must not be wasted. Dropped connections, tiny edits and repeated uploads of the same file should cost little.
- Strong consistency for the file tree. If a device says "your folder is at version 812", that must be true. Showing a half finished upload as a complete file is a bug.
- Highly available. Reads should keep working during partial outages.
- Scales by adding machines, not by buying a bigger one.
4. Back of the Envelope Numbers
4.1 Storage
Logical data = 50M users x 4 GB = 200M GB = 200 PB
Files = 50M users x 1,000 = 50 billion files
Average file = 200 PB / 50B = 4 MBDeduplication (storing identical pieces once, explained in section 5) saves some of that. Say 25 percent, which is a cautious number for a consumer service where lots of people hold the same PDFs, installers and songs.
Stored after dedup = 200 PB x 0.75 = 150 PB
Saved = 50 PB4.2 Chunks
Each file is cut into chunks of 4 MB (section 9.1 explains why 4 MB).
Chunks (before dedup) = 200 PB / 4 MB = 200e15 / 4e6 = 50 billion
Unique chunks (75%) ~ 37.5 billion
Chunk index row ~ 100 bytes (hash, size, location)
Chunk index size = 37.5B x 100 B ~ 3.75 TBSmall files are one chunk that is less than 4 MB, so the true count is a bit higher. The shape is right.
4.3 Metadata
Metadata means the information about files, not the bytes inside them: names, folders, versions, which chunks belong to which file.
files = 50B rows x 300 B = 15 TB
file versions = 3 kept on average
= 150B rows x 150 B = 22.5 TB
version_chunks = 150B rows x 40 B = 6 TB
Total metadata ~ 45 TBA good database shard holds about 500 GB comfortably, so 45 TB / 500 GB is roughly 90. We plan for about 100 shards.
Notice the ratio: 45 TB of metadata against 150 PB of data. Metadata is about 3,000 times smaller than the bytes, but every single user action goes through it. It is small and extremely busy. That is why the two are stored in completely different systems.
4.4 Traffic
Changes per day = 10M active users x 20 = 200M
Per second = 200M / 86,400 ~ 2,300
Peak (3x) ~ 7,000 file commits per second
Data uploaded per change ~ 2 MB (many small files, some single changed chunks)
Per day = 200M x 2 MB = 400 TB
Per second = 400 TB / 86,400 ~ 4.6 GB/s ~ 37 Gbps
Peak (3x) ~ 110 GbpsEach change must reach about 1.5 other devices, so downloads are 200M x 1.5 x 2 MB = 600 TB a day, about 7 GB/s. Uploads and downloads are about the same size. This is not a "read heavy" system like a video site. Files are written, read once or twice by the user's own devices, then mostly sit still.
4.5 Metadata Query Rate
Each commit does about 5 database operations (read the file, insert a version, insert chunk links, update the file, add a journal entry).
Peak is 7,000 commits/s x 5 = 35,000 operations a second. Across 100 shards that is 350 per shard, which is easy. We do not need exotic technology for the file tree, we need good sharding and good indexes.
4.6 Online Connections for Sync
Say half of the 20M devices are online at the same moment and each holds one open "tell me when something changes" request.
Open connections = 10M
Each lasts ~ 60 seconds, then reconnects = 10M / 60 ~ 167,000 new requests per second
One server holds ~ 100,000 idle connections, so 10M / 100K = 100 serversNothing is scary here, but this fleet exists, separate from the API servers, since holding connections open is a different job from answering requests.
4.7 Money
Rough numbers: $20 per TB per month for object storage, $0.05 per GB for outgoing data at list price.
Storage = 150 PB = 150,000 TB x $20 = $3M per month (dedup saves 50,000 TB x $20 = $1M)
Download = 600 TB per day = 600,000 GB x $0.05 = $30K per day ~ $0.9M per monthCompare with a video site, where bandwidth is the huge bill. Here storage is the biggest bill, so the money tricks are about storing less: dedup, only keeping changed chunks of old versions, and moving untouched files to a cheaper cold tier. Say this contrast out loud, because it shows you know the cost drivers differ.
5. High Level Design
Three ideas drive the whole thing. Learn these and the rest follows.
5.1 Split Files Into Chunks
Never treat a file as one blob. Cut it into fixed pieces of 4 MB called chunks. A 1 GB file is 256 chunks. A 10 KB note is one small chunk.
Why bother? Think about editing one line in a 1 GB video project file. Without chunks, you upload 1 GB again. With chunks, one chunk changed, so you upload 4 MB. That is 256 times less. Chunks also give us resumable uploads, for free, as section 10 shows.
5.2 Name Each Chunk by Its Fingerprint
For every chunk, compute a hash. A hash (here SHA-256) is a fixed length fingerprint of the bytes: the same bytes always give the same fingerprint, and different bytes give a different one, for all practical purposes. Think of it as an ISBN number for a book. If two people have a book with the same ISBN, it is the same book, and you do not need two copies on the shelf.
So the chunk's name in storage is its hash. Before uploading anything, the client sends the list of hashes to the server and asks "which of these do you not have yet?". The server answers with the missing ones, and only those are uploaded. This one trick gives three wins:
- Edit a big file: only the changed chunks are new.
- Two files, or two users, with identical content: stored once.
- Retry after a crash: the chunks already uploaded are simply "already have".
5.3 Keep Metadata and Bytes in Different Places
The bytes go to blob storage, an object store built for huge numbers of large, never edited files. The file tree goes to a database, built for small, constantly changing, carefully consistent facts. They have almost nothing in common, so mixing them hurts both.
A file in the system is nothing but a row in the database that says "this file, at this version, is made of these chunks, in this order". The chunks sit in the blob store. Reading a file means reading its row, then fetching its chunks.
5.4 The Diagram
5.5 Walking Through Each Box
Client. The app on each device is cleverer than it looks. It watches the folder for changes, cuts files into chunks, computes hashes, keeps a small local database of what it last synced, uploads, downloads, and puts files back together. Much of the logic lives here, so the servers can stay simple.
API servers. Answer metadata questions: which chunks are missing, commit this file, list changes, restore version. They never carry file bytes. A client uploads a chunk straight to blob storage with a short lived signed address. That keeps 37 Gbps of upload traffic off the API fleet.
Blob storage. An object store holding chunks. The key is the hash, for example chunks/ab/cd/abcd1234.... A chunk is never changed, only written once and later deleted, which makes the store simple and very safe: if the same chunk is uploaded twice, the second write puts the exact same bytes in the exact same place.
Chunk index. A fast key value lookup from hash to "yes we have it, here is its size and location". This answers "which of these 40 hashes are missing?" in a few milliseconds. About 3.75 TB, sharded by hash.
Metadata DB. The file tree: folders, files, versions, who has access. Sharded by namespace, which is the idea in section 7. It is the source of truth for what the folder looks like.
Message bus and notification servers. When a commit succeeds, a tiny event goes on a bus. Notification servers, which hold the open connections to devices, tell the right devices "something changed". They carry no file data, just a nudge.
Cleanup job. Deletes chunks that no version refers to any more. Done slowly and carefully, since deleting data is the one action you cannot undo.
6. API Design
6.1 Upload and Commit
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/chunks/prepare | Send a list of chunk hashes, get back which are missing and signed upload URLs for them |
| PUT | <signed URL> | Upload one chunk straight to blob storage |
| POST | /api/v1/files/commit | Create a new version of a file from a list of chunk hashes |
| DELETE | /api/v1/files/:fileId | Delete a file (soft delete, versions kept) |
| POST | /api/v1/files/:fileId/move | Rename or move a file or folder |
Prepare:
{
"namespaceId": "ns_9021",
"chunks": [
{ "hash": "9f2c...e1", "size": 4194304 },
{ "hash": "77ab...03", "size": 4194304 },
{ "hash": "c410...5d", "size": 1048576 }
]
}Response:
{
"missing": [
{ "hash": "77ab...03", "uploadUrl": "https://blob.example/chunks/77/ab/...?sig=...", "expiresInSeconds": 900 }
]
}Only one of three chunks needs uploading. The other two already exist, perhaps from this same user's previous version, or from someone else's identical file.
Commit:
{
"namespaceId": "ns_9021",
"parentId": "f_5510",
"name": "report.docx",
"baseVersion": "v_8812",
"chunks": ["9f2c...e1", "77ab...03", "c410...5d"],
"sizeBytes": 9437184,
"modifiedAt": "2026-10-10T09:14:00Z",
"deviceId": "dev_laptop_1"
}baseVersion is the version the user started editing from. The server uses it to spot conflicts (section 9.3). For a brand new file it is empty.
Response 200 OK:
{
"fileId": "f_7701",
"versionId": "v_8813",
"journalSeq": 20417,
"conflict": false
}To keep small files cheap, a commit request can carry up to 100 files. Otherwise, a folder of 5,000 tiny notes would be 5,000 round trips.
6.2 Sync
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/sync/poll | Long poll. Returns when any listed namespace has news, or after 60 seconds |
| GET | /api/v1/sync/changes | List changes in a namespace since a cursor |
| GET | /api/v1/files/:fileId/versions/:versionId | Get the chunk list and signed download URLs |
Long poll request:
{ "cursors": { "ns_9021": 20417, "ns_5532": 880 } }Response when something changed:
{ "changed": ["ns_9021"] }Or after 60 quiet seconds: { "changed": [] } and the client asks again. Then the client calls the changes endpoint with its cursor and gets back a page of journal entries (each with a seq, an operation such as UPSERT or DELETE, the file id and the version id), plus a nextCursor and a hasMore flag.
6.3 Versions, Sharing, Links
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/files/:fileId/versions | List versions of a file |
| POST | /api/v1/files/:fileId/restore | Restore an old version (creates a new head version) |
| POST | /api/v1/namespaces/:id/members | Share a folder with a user as VIEWER or EDITOR |
| POST | /api/v1/links | Create a share link for a file, with optional expiry and password |
| GET | /s/:token | Open a shared link (no login needed) |
7. Data Model
7.1 The Key Idea: Namespaces
A namespace is one tree of files that has one list of members. Every user gets a personal namespace. A shared folder becomes its own namespace, and the people it is shared with mount it into their own tree.
Why this matters: everything that must be consistent together (the files, their versions, the change log, the permissions) lives under one namespace id. We shard by namespace_id, so a commit touches exactly one database shard and can be a normal transaction. If we sharded by file id or by user, committing to a shared folder would need a transaction across shards, and that is where bugs and slowness come from.
The cost: one very busy shared folder (a whole company folder with 5,000 people) lives on one shard. That is acceptable because a single folder still has a limited write rate, and we can move big namespaces to dedicated shards.
7.2 Tables
namespaces
| Column | Why |
|---|---|
namespace_id | Primary key and shard key |
kind | PERSONAL or SHARED |
owner_user_id | Who pays the storage quota |
last_seq | The latest journal number. Incremented inside each commit |
memberships (kept in a small separate store, sharded by user)
| Column | Why |
|---|---|
user_id | Partition key. At startup a device asks "which namespaces do I have?" |
namespace_id | Part of the key |
role | OWNER, EDITOR, VIEWER |
mount_path | Where this namespace shows up in the user's tree |
Permission check on every request is one lookup by (user_id, namespace_id), cached for a minute, and removed from the cache immediately when a member is removed.
files
| Column | Why |
|---|---|
namespace_id, file_id | Primary key, in that order so one namespace stays together on one shard |
parent_id | The folder this item sits in |
name | File or folder name |
is_folder | True for folders |
head_version_id | Pointer to the current version. Null for folders |
is_deleted | Soft delete. The row stays so versions can be restored |
modified_at | Shown to the user |
There is a unique rule on (namespace_id, parent_id, name) for items that are not deleted, so two files cannot share one name in one folder.
Why parent_id and not a full path like /Work/2026/report.docx? Because of renames. If you store full paths, renaming /Work means rewriting the path on every file under it, which could be a million rows. With a parent pointer, renaming or moving a folder is one row update, no matter what is inside. That is why moving a huge folder is instant in a good cloud drive. The cost is that building a full path means walking up the parents, which the client and a small cache handle.
file_versions
| Column | Why |
|---|---|
namespace_id, version_id | Primary key |
file_id | Which file |
base_version_id | The version this one was edited from. This is what makes conflicts detectable and gives history a shape |
size_bytes | For quota and display |
chunk_count | Quick sanity check against the chunk list |
device_id | Which device made it |
created_at | Used for the 30 day retention rule |
Versions are never changed after they are written. A new edit is a new row.
version_chunks
| Column | Why |
|---|---|
namespace_id, version_id, position | Primary key. position keeps the chunks in order |
chunk_hash | Which chunk goes at that position |
Why a separate table and not a list stored inside the version row? A 50 GB file has 12,800 chunks. At 32 bytes per hash that is 400 KB for one row, which is too much for a normal row. As a table, the list can be read in pages.
journal
| Column | Why |
|---|---|
namespace_id, seq | Primary key. seq grows by one for every change in the namespace |
file_id, op | What changed: UPSERT, DELETE, MOVE |
version_id | The new version, if any |
created_at | Old entries are deleted after 90 days |
The journal is the heart of sync. A device only needs to remember one number per namespace: "I have seen everything up to seq 20417". It asks for entries after that number and applies them in order.
If a device was off for more than 90 days, its number is too old and the journal has been trimmed. The server says so, and the device does a full comparison of its folder against the tree. Rare, slower, but correct.
chunks (the index, a key value store sharded by hash)
| Column | Why |
|---|---|
chunk_hash | Primary key. Hash is random, so load spreads evenly |
size_bytes | Sanity check |
created_at | Used by the cleanup job to avoid deleting brand new chunks |
There is no reference count on chunks. A count would need to be updated, in a different shard from the commit, on every commit and delete. That is a cross shard write on the hottest path, and counts can drift after crashes. We find unused chunks with a periodic sweep instead (section 10).
share_links
| Column | Why |
|---|---|
token | Primary key. 128 random bits, base 62 encoded. Unguessable |
namespace_id, file_id | What it points to |
created_by | Owner of the link |
expires_at, password_hash | Optional limits |
revoked | Turn a link off without deleting the history |
8. Key Flows
8.1 Saving an Edit
The order is the safety net. Chunks go up first, the commit comes last, and the commit is a single database transaction. Until the commit lands, nobody sees the file change. So a crash halfway through an upload leaves some extra unused chunks behind and never a half broken file.
The transaction, in plain SQL:
BEGIN;
SELECT head_version_id FROM files
WHERE namespace_id = ? AND file_id = ? FOR UPDATE;
-- if head_version_id is not the client's baseVersion, take the conflict path
INSERT INTO file_versions (...) VALUES (...);
INSERT INTO version_chunks (...) VALUES (...), (...), (...);
UPDATE files SET head_version_id = ?, modified_at = ? WHERE namespace_id = ? AND file_id = ?;
UPDATE namespaces SET last_seq = last_seq + 1 WHERE namespace_id = ? RETURNING last_seq;
INSERT INTO journal (namespace_id, seq, file_id, op, version_id) VALUES (...);
COMMIT;The FOR UPDATE lock on the file row is what makes two simultaneous commits take turns, so the second one sees the first one's result.
8.2 Another Device Picks It Up
The phone already held two of the three chunks from the previous version, so it downloads 4 MB, not 9 MB. The saving is the same as on upload, in the other direction.
8.3 Two Devices Edit the Same File
9. Deep Dives
9.1 How to Cut the File: Fixed Size or Content Based
The problem. We want a small edit to cause a small upload. How we choose chunk boundaries decides whether that works.
Option A: fixed size chunks. Every 4 MB, cut. Chunk 1 is bytes 0 to 4 MB, chunk 2 is 4 to 8 MB, and so on. Very simple, very fast to compute, works for editing in place or appending. The weakness: if you insert one byte at the start of a file, every byte after it moves by one, so every chunk changes its content and the whole file is uploaded again.
Option B: content defined chunking. Slide a small window over the file and compute a rolling hash. Whenever the hash matches a special pattern, cut there. The boundaries depend on the content, not on the byte position, so an insertion near the start only changes the chunks around it, and the later boundaries land at the same content as before. Chunk sizes vary, say between 1 MB and 8 MB with an average of 4 MB.
The choice for this design: fixed 4 MB. My reasoning, with an honest caveat.
Most large files that people sync are photos, videos, archives and installers. They are either not edited at all, or rewritten whole, or appended to. Insertion at the front of a big file is rare. Fixed chunks cost almost no CPU, which matters on a phone with a small battery. And the boundaries are easy to explain and debug.
The caveat: for things like a large database file or a virtual machine disk, content defined chunking wins clearly, and some backup tools use it for exactly that reason. If this product had a lot of those users, I would add option B in the desktop client as a later upgrade. The server does not care, because it only sees hashes and lengths.
Why 4 MB and not 1 MB or 64 MB? Let us count.
Chunk size Chunks for 200 PB Index size at 100 B/row
1 MB 200 billion 20 TB
4 MB 50 billion 5 TB
64 MB 3.1 billion 0.3 TBSmall chunks give finer deduplication and smaller re-uploads, but a bigger index and more requests per file. Large chunks make the index small, but a one byte edit re-uploads 64 MB, and a dropped connection wastes more. 4 MB is the usual middle. It is big enough that a 1 GB file is only 256 requests, small enough that a retry costs seconds.
One security point worth mentioning. If the server says "I already have that chunk, skip the upload", a cheater who knows only the hash of someone's private file could claim to have it, skip the upload, and then download it. The hash is enough to "prove" ownership. Two fixes: only dedup within one account (loses the cross user saving), or make the client prove it really has the bytes, by asking for the hash of a few random byte ranges of the chunk mixed with a one time number. For dedup across users I would take the proof route, and always have the server check that uploaded bytes really hash to the claimed name.
9.2 Keeping Devices in Sync: Polling, Long Polling or Push
The problem. When one device commits a change, all the user's other online devices should know within seconds. A device cannot receive a call from the server unless it has a connection open for that.
Option A: poll every few seconds. The client asks "anything new?" every 30 seconds. Dead simple. But look at the cost:
10M devices online / 30 seconds = 333,000 requests per second
Nearly all of them answer "no, nothing new"
Average delay before a change shows up = 15 secondsThat is a lot of useless work and a slow feel.
Option B: long polling. The client asks "anything new since cursor 20417?" and the server does not answer until there is something, or 60 seconds pass. The request just waits. When the commit arrives, the server answers at once, and the client asks again.
Reconnects = 10M / 60 = 167,000 per second (half of A, and mostly useful)
Delay = the time it takes to forward a message, under a secondOption C: a WebSocket or similar always open connection. Similar memory cost per device, no reconnect cycle. Needs servers and every proxy and firewall in between to be happy with long lived upgrades. Some corporate networks are not.
The choice: B, long polling. It is ordinary HTTP that works through every proxy, it balances across servers like any other request, and the 60 second timeout doubles as a health check, since a dead connection is replaced within a minute. A WebSocket would be fine too, and I would not fight anyone who picked it. For phones in the background, skip the open connection entirely and use the phone's own push service (APNs or FCM) to wake the app.
Now, how does the right notification server learn about a commit? The simple way: every commit event goes to all 100 notification servers.
Peak commits = 7,000 per second
Each event ~ 50 bytes
Each notification server receives 7,000 x 50 B = 350 KB per secondThat is tiny. Each server checks its own in memory map from namespace id to waiting requests, and wakes the ones that match. No complicated subscription routing. I would start here, and only add routing if the numbers grew by a factor of 100.
Two rules keep this reliable.
The notification carries no data. It means "go and look". If it is lost, nothing breaks, because the client re-polls within 60 seconds anyway, and it asks with its cursor, so it gets every missed change in order.
The cursor, not the notification, is the source of truth. The client always asks "everything after seq N". Whether it got zero, one or ten notifications in between does not matter. This one rule makes the whole system forgiving of lost, duplicated and late messages.
9.3 Conflicts: Two Devices, One File
The problem. The laptop and the phone both start from version 8813 of report.docx. The phone was in a tunnel. Each makes a change. Now the server has two different next versions, and both people worked hard on theirs.
Option A: last writer wins. Whichever commit arrives last replaces the other. Simple. Also quietly destroys someone's work, and the person never knows. For a service whose promise is "never lose a file", that is a broken promise.
Option B: keep both, as a conflicted copy. Use the baseVersion check: a commit is only accepted as the new head if its base version is the current head. If the server sees that the current head moved on, the later commit is saved as a separate file named report (conflicted copy from Phone, 2026-10-10).docx, in the same folder. Both versions exist, both sync to every device, and the human merges them by hand.
Option C: merge automatically. Works for text, and for documents inside an editor built for it (operational transforms or CRDTs, the machinery behind real time co-editing in Google Docs). The server would need to understand the file format, which is impossible for a general file store holding photos, zips and spreadsheets.
The choice: B. It never loses data, it needs no understanding of the file, and the rule is easy to explain: "if two devices edited the same file from the same starting point, you get two files". The people who dislike it are annoyed for ten seconds, which is far better than being angry about lost work for a week.
Details a good answer includes:
- The check happens inside the same transaction that takes the row lock, so two commits arriving at the same instant are put in order by the lock. The one that loses sees a changed head, and takes the conflict path.
- Retrying a commit must be safe. The client sends a unique request id, and if the same commit is sent twice (the response was lost), the server returns the first result and does not create two versions.
- Edit versus delete. One device deletes the file, another edits it from the old version. The edit wins: the file comes back with the edited content. Losing an edit is worse than an unwanted file.
- Two devices create the same new filename at once. Both have an empty
baseVersion, and the unique name rule in the folder catches the second one, which becomes a conflicted copy. - Rename versus edit is not a conflict at all, because moves change the
filesrow and edits add a version. Both apply.
10. Failure Handling
| What breaks | What the user sees | What we do |
|---|---|---|
| Client crashes during upload | Sync resumes on restart | Client hashes the file again, calls prepare, gets back only missing chunks. Chunks already stored are skipped, so resume is free and needs no server session |
| Connection drops mid chunk | That one chunk retries | Only up to 4 MB is repeated. Never the whole file |
| Large 50 GB upload takes hours | Progress bar, survives restarts | 12,800 chunks, uploaded 4 at a time. The commit comes last, so nothing appears half done |
| Commit succeeds but response lost | Client retries | Request id makes the retry return the original result |
| A chunk is uploaded but corrupt | Commit is refused | Blob store checks the hash on upload. Commit verifies each chunk exists |
| Metadata shard primary dies | A few seconds of errors for that shard's users | Promote a replica. Other shards are unaffected, since each namespace lives on one shard |
| Blob store node dies | Nothing | Store keeps several copies or uses erasure coding (data split with extra repair pieces), across data centres |
| Notification server dies | Delay of up to 60 seconds in sync | Devices reconnect to another server, and the cursor catches them up |
| Device offline for days | Folder catches up on reconnect | Pull the journal since the cursor. If older than 90 days, do a full compare |
| User deletes by mistake | File gone from folder | Soft delete. Restore from the deleted files list within 30 days |
| Share link leaked | Others can open the file | Owner revokes the link. The check on revoked and expires_at happens on every request |
The cleanup job deserves a closer look, because deleting data is where a storage service can do real harm. It runs weekly. It scans version_chunks to build the set of hashes still in use, and then deletes chunks that are not in that set and are older than 7 days. The 7 days protect chunks uploaded but not yet committed (a slow upload of a huge file might take hours or days to finish). It also runs a "dry run" mode first that only reports how many chunks it would delete, and an alert fires if that number is suspiciously large. A bug in this job is a data loss event, so it gets more care than almost anything else.
Retention works the same way. A job removes versions older than 30 days that are not the head, which removes their version_chunks rows, and the next sweep frees any chunks that nothing else uses.
11. Tradeoffs
| Decision | Chosen | Alternative | Reason |
|---|---|---|---|
| Chunking | Fixed 4 MB | Content defined | Simple, cheap on phones. Content based wins for in place edits of huge files, so keep it as an upgrade |
| Chunk name | Content hash | Random id | Free dedup, idempotent uploads, free resume |
| Dedup scope | Across users with proof of possession | Per account only | Saves about 25 percent of storage (worth about $1M a month) without leaking data |
| Metadata store | Relational, sharded by namespace | Key value store | Commit needs a real transaction on one shard |
| Folder structure | Parent id pointers | Full paths | Renaming or moving a folder is one row |
| Chunk reference counts | None, periodic sweep | Live counts | Counts need cross shard writes on every commit and can drift |
| Sync notification | Long poll, nudge only | Polling, WebSocket | Works everywhere, 167K requests per second instead of 333K, and loses nothing if a nudge is dropped |
| Sync truth | Per namespace journal and cursor | Notification payload | Handles lost and repeated messages naturally |
| Conflicts | Conflicted copy | Last writer wins, auto merge | Never loses work, and needs no knowledge of file formats |
| Versions | 30 days, only changed chunks stored | Full copy per version | A 1 GB file edited 10 times costs about 1.04 GB, not 11 GB |
12. Interview Talking Points
- Open with the questions: how many users, file sizes, how often files change, whether co-editing is needed. Then say you are building whole file sync, not live document editing.
- Say the three core ideas early and plainly: cut files into 4 MB chunks, name each chunk by its hash, and keep metadata in a database away from the bytes in blob storage. Everything else grows from these.
- Show the saving with a number: a 1 GB file with one edited chunk uploads 4 MB, not 1 GB. And a retry after a crash costs nothing, because the client just asks "which chunks do you still need?".
- Walk the commit order: chunks first, then one transaction that checks the base version, adds the version, moves the head pointer, bumps the sequence number and writes the journal. Say why sharding by namespace keeps that transaction on one shard.
- Explain sync with the cursor idea: each device remembers one number per namespace, long polling tells it when to ask, and lost notifications are harmless because the cursor is the truth.
- Handle conflicts with
baseVersion. Name the three options (last writer wins, conflicted copy, merge) and say why you chose conflicted copies for a general file store. - Cover sharing briefly: shared folders are their own namespaces with members and roles, links are random 128 bit tokens that can expire or be revoked.
- Close on cost and safety: storage is the biggest bill here (about $3M a month at 150 PB), dedup and keeping only changed chunks of old versions are the savings, and the cleanup job is carefully guarded with a grace period because deleting data is the one thing you cannot undo.