Design File Sync
Design a cloud file sync service: chunking and content hashes for dedup and delta sync, a consistent metadata journal, conflicts and device notifications.
On this page
You save a spreadsheet on your laptop. Before you reach for your phone, the new version is already there, and the copy on your home computer updated itself overnight. If you edit the same file in two places while offline, nothing is silently lost: you get both versions and decide.
Interviewers like this question because it mixes two very different systems. One moves large amounts of bytes and must avoid sending anything twice. The other tracks small metadata records and must be strictly correct, because a wrong file list destroys someone's work. The steps follow A Framework for Any Design.
Clarify the problem
Design a file sync service, like a shared folder that follows you across devices.
So a folder on each device, kept in sync through the cloud. Do we need sharing between users, version history, and editing while offline?
Shared folders yes, offline editing yes. Keep 30 days of versions.
How many users, how much data each, and how often do files change?
10 million users, about 2 GB each on average, around 3 devices per user. Say 3 million people change something on a given day.
Then my priorities are: never lose or silently overwrite a file, never upload bytes the server already has, and show a change on other devices within a few seconds. File lists must be consistent: every device should see changes to a folder in the same order. I'll leave out real-time co-editing inside documents; that is a different product.
- Functional: upload, download, update, delete and move files; sync changes to a user's other devices; share folders with other users; keep 30 days of versions; work offline and sync later.
- Non-functional: no acknowledged change is ever lost (durability); a folder's changes are seen in one order by every device (strong consistency per folder); other devices see changes within about 5 seconds when online; upload only what changed.
- Out of scope: real-time co-editing, search inside files, previews.
Estimate the scale
- Stored data: 10 million users × 2 GB = 2 × 1016 B = 20 PB before deduplication and before redundancy.
- Changes: 3 million active users × 20 saved changes each = 60 million file changes a day. 60 × 106 ÷ 86,400 ≈ 700 a second on average; assume a working-hours peak of 3x, about 2,100 a second.
- Upload bandwidth: if a change uploads 1 MB on average, 60 million × 1 MB = 60 TB a day, about 700 MB/s on average.
- Connected devices: 10 million users × 3 devices = 30 million devices. If 30% are online at once, 9 million devices wait to hear about changes.
Files are split into 4 MB chunks, and the server keeps one index row per chunk (hash, location, reference count). How big is that index?
- 20 PB of stored data
- 4 MB per chunk
- About 100 bytes per index row
Show the working
- 2 × 1016 B ÷ 4 × 106 B = 5 × 109 chunks
- 5 × 109 × 100 B = 5 × 1011 B = 500 GB
Estimate 500 GB (anything from 150 GB to 1.5 TB is a good answer)
Small enough for a sharded database. Smaller chunks would find more duplicates but grow this index in proportion.
A user changes a few cells in a 200 MB file. How many bytes does the client upload with 4 MB chunks?
- The file is 200 MB
- The edit falls inside one 4 MB chunk
- Unchanged chunks are already on the server
Show the working
- 200 MB ÷ 4 MB = 50 chunks
- Only 1 chunk's hash changed, so 1 × 4 MB = 4 MB is uploaded instead of 200 MB, 50 times less
Estimate 4 MB (anything from 2 MB to 20 MB is a good answer)
Write the calls a client makes to save a changed file without uploading what the server already has, and how another device finds out about the change.
Compare with a model answer
The client sends the list of chunk hashes first; the server answers with the ones it is missing. The client uploads those, then commits the new version, naming the version it started from. Other devices hold a long poll that says "this folder changed", then ask for changes since their last cursor.
Define the API
POST /v1/commit
body { namespace_id, path, base_rev: 7, commit_id, size, chunks: [sha256, ...] }
200 { rev: 8, journal_seq: 1042 }
412 { missing: [sha256, ...] } upload these first, then retry
409 { current_rev: 8 } someone else changed it since rev 7
PUT /v1/chunks/{sha256} (raw bytes, at most 4 MB)
201 stored 200 already had it 400 hash does not match bytes
GET /v1/chunks/{sha256} -> bytes
GET /v1/changes?namespace_id=N&cursor=1041
200 { entries: [ { path, rev, chunks, deleted } ], cursor: 1042, has_more }
GET /v1/notify?cursors=N:1041,M:88 long poll, up to 60 s
200 { changed: ["N"] } or 204 nothing changed, poll againThe decision worth arguing is chunks before metadata. The commit only succeeds if every chunk it names is already stored, so a file record can never point at bytes that do not exist. The cost is a second round trip when chunks are missing (the 412). The alternative, writing metadata first and bytes after, leaves other devices downloading a file whose bytes have not arrived, which is worse. Naming chunks by their hash also makes uploads safe to retry: sending the same chunk twice stores it once. The commit carries a client-made commit_id for the same reason: if the reply is lost and the client retries, the server finds the commit already applied and returns it, instead of a false 409 against the device's own edit.
Sketch the records for folders, files, versions and chunks. How does a device ask "what changed since I last looked"?
Compare with a model answer
A file version is a list of chunk hashes. Chunks are stored once, by hash. Each shared folder or user root (a namespace) has a journal: an ordered log of changes with a sequence number. A device remembers the last sequence number it applied, its cursor, and asks for entries after it.
Model the data
namespaces: namespace_id PK, owner_id, next_seq BIGINT
files: (namespace_id, path) PK, file_id, latest_rev INT, deleted BOOL
revisions: (file_id, rev) PK, size, chunk_hashes BLOB, -- ordered list
device_id, created_at -- kept 30 days
journal: (namespace_id, seq) PK, file_id, rev, op -- the change log
members: (namespace_id, user_id) PK, role -- sharing
-- sharded by hash
chunks: sha256 PK, blob_key, size, ref_count
-- chunk bytes live in object storage under their sha256The metadata needs transactions: a commit checks the base revision, writes a new revision, bumps the file's latest revision and appends a journal entry, all or nothing. That points to a relational database, sharded by namespace, so every change to one folder lands on one shard and gets one order. Chunk bytes go to object storage, keyed by hash. The chunk index is a simple key-value lookup sharded by hash.
Draw how a save on one device reaches another. Keep the byte path and the metadata path separate.
Compare with a model answer
A block service stores and serves chunks; a metadata service runs commits and the journal; a notification service holds long polls and tells devices which folders changed. Devices fetch the details themselves, so a lost notification is never lost data.
High-level design
request and replyasynchronous message
- base rev
- 7
1The laptop saves
budget.xlsx(200 MB). The client splits it into 50 chunks and hashes each one. Only one hash differs from revision 7.- base rev
- 7
2It tries to commit revision 8 with all 50 hashes. The metadata service checks the chunk index: one is missing, so it answers
412.- base rev
- 7
- uploaded
- 4 MB
3The laptop uploads that one chunk: 4 MB instead of 200 MB. The block service checks the bytes match the hash before storing them.
- rev
- 8
- journal seq
- 1042
4It retries the commit. In one transaction: the file is still at revision 7, so revision 8 is written and journal entry 1042 is appended.
- rev
- 8
- journal seq
- 1042
5A feed that reads the journal puts a change event for this folder on a queue. Because events come from the journal itself, a crash right after a commit cannot skip one. The laptop does not wait.
- phone cursor
- 1041
6The phone has a long poll open, holding cursor 1041. The notification service answers it: "this folder changed". No file data is in the message.
- phone cursor
- 1042
7The phone asks for changes after 1041 and gets one entry:
budget.xlsxat revision 8 with its 50 hashes.- phone cursor
- 1042
- downloaded
- 4 MB
8It already holds 49 of those chunks, so it downloads one and rebuilds the file locally.
- rev
- 8
9Meanwhile the desktop edited the same file offline, starting from revision 7. Its commit says base 7, but the file is at 8:
409. Nothing is overwritten.- phone cursor
- 1042
10If the notification service is down, devices fall back to asking for changes every minute. Sync is slower, but no change is missed, because the journal and cursors are the truth.
Deep dives
Interviewers push on how files are cut into chunks, how the metadata stays consistent, what happens on conflicts, and how devices learn about changes.
Chunking and content hashing
Name every chunk by a cryptographic hash of its bytes, such as SHA-256. Two chunks with the same hash have the same bytes, for every practical purpose, so the server stores each distinct chunk once. That one idea gives three wins: delta sync (upload only chunks whose hash changed), deduplication (the same chunk in two files or two versions is stored once), and safe retries (re-sending a chunk changes nothing).
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Whole file | Simplest; one hash per file. | A one-byte change re-uploads the whole 200 MB file. | Small files only, under one chunk anyway. |
| Fixed 4 MB chunks Usual default | Easy to compute and to explain. Edits in place change one chunk. | Inserting bytes near the start shifts every boundary after it, so every later chunk gets a new hash. | Files edited in place, like disk images and databases. A good first answer. |
| Content-defined chunks | Boundaries follow the content, so an insertion changes only the chunks around it. | Chunk sizes vary; needs a rolling hash over the bytes and a minimum and maximum size. | Documents where text is inserted in the middle. What I would move to next. |
Content-defined chunking slides a small window, say 48 bytes, over the file and computes a cheap rolling hash at each position. Wherever the low 22 bits of that hash are all zero, it cuts. That happens on average once every 222 ≈ 4.2 million bytes, so chunks average about 4 MB. Because the cut depends only on the nearby bytes, inserting a paragraph moves the boundaries near it and leaves the rest where they were.
One judgement call worth raising: deduplicating across users means an upload that finishes instantly reveals that someone else already has that exact file. Some services limit deduplication to a user's own files for that reason. I would start per user and say what cross-user deduplication would save and cost.
The metadata service and consistency
Bytes can be eventually consistent; a chunk either exists or does not, and its content never changes. File lists cannot. If a device applied "rename A to B" before "create A", it would end up with the wrong folder. So every change to a namespace gets the next sequence number in that namespace's journal, inside the same transaction that changes the file record. Devices apply entries in sequence order and remember their cursor.
This gives a precise guarantee: within one namespace, every device sees the same changes in the same order, and never sees revision 8 without the chunks it needs. It does not order changes across two namespaces, and it does not need to. That is why the database is sharded by namespace: one shard, one order, no cross-shard transactions for normal saves. The cost is that a single huge shared folder lands on one shard; split very large namespaces or give them a dedicated shard.
If the metadata database fails over to a replica, it must be one that has every committed transaction (a synchronous replica, or a consensus-based database). An asynchronous replica can be behind: a commit acknowledged to the laptop could vanish, and the phone might already have applied it. See replication for why lag matters here.
Conflicts
Two devices edit the same file while one is offline. Something has to give.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Last writer wins | No user interaction; always one version. | Silently throws away an edit, decided by whose clock or upload finished last. | Never for user files. |
| Conflicted copy Usual default | Nothing is lost. The second writer's version is saved as budget (Desktop's conflicted copy).xlsx next to the original. | The user has to merge by hand. | Any file type the server cannot understand. My default. |
| Automatic merge | No duplicates when edits touch different parts. | Only possible for formats you can parse and merge; a wrong merge corrupts a file. | Specific formats, such as plain text, with a tested merge. |
The mechanism is optimistic concurrency: every commit names its base_rev, and the server accepts it only if that is still the latest revision. The loser gets 409, uploads its version under a conflicted-copy name, and both appear on every device. Deletes conflict too: if one device deletes a file another just edited, keep the edit.
Notifying other devices
9 million devices online at once need to hear about changes within seconds. Polling every 5 seconds would be 9 million ÷ 5 = 1.8 million requests a second, nearly all answering "nothing new". Instead each device holds one long poll (or a WebSocket) to the notification service, listing its namespaces and cursors. The service answers as soon as an event arrives for one of them, or after 60 seconds with "nothing changed". That is 9 million ÷ 60 = 150,000 empty answers a second, against 1.8 million.
The notification carries no file data, only "namespace N changed". It is a hint, not the source of truth. If one is lost, the next poll catches the device up: the poll carries the device's cursors, and the service answers at once when a cursor is behind the namespace's latest sequence number. The device then asks for changes since its own cursor. Designing it this way lets the notification path be fast and best-effort while the journal stays strict. The realtime communication lesson compares long polling and WebSockets.
Bottlenecks and failures
At 10x (200 PB, about 21,000 commits a second at peak, 90 million online devices) the chunk store just grows; object storage scales by adding capacity. The metadata database grows by adding shards, which namespace sharding allows, and the hottest single namespaces get their own. The notification service scales by adding connection servers, partitioned by device. Storage cost becomes the main bill, so redundancy moves from three full copies to erasure coding, and old versions past 30 days are garbage-collected by decrementing chunk reference counts.
| When this dies | What happens | What you do |
|---|---|---|
| A block service server | Uploads and downloads to it fail. | Clients retry on another; chunk uploads are idempotent by hash. |
| A metadata shard primary | Commits for its namespaces fail. | Fail over to a synchronous replica; clients retry the commit with the same commit_id, so it applies once. |
| The notification service | Changes reach other devices late. | Devices fall back to polling every minute; nothing is lost. |
| The change-event queue | Notifications stop. | Same fallback; the journal is untouched. |
| A device mid-upload | Some chunks are stored with no file pointing at them. | Nothing breaks; garbage collection removes chunks with no references after a grace period. A commit marks each chunk it names as in use, so collection never deletes a chunk a new commit is about to point at. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Uploads whole files to object storage with a table of file paths, and polls for changes. |
| Mid | Splits files into hashed chunks for delta sync, separates metadata from bytes, and adds push notifications. |
| Senior | Commits metadata only after chunks exist, keeps an ordered journal per namespace with device cursors, handles conflicts with base revisions and conflicted copies, treats notifications as hints, and explains why fixed chunks fail on insertions. |
Why not just keep whichever version arrived last?
Because that silently destroys someone's work, and the order depends on whose upload finished first, which the user cannot see. Every commit names the revision it started from. If that is no longer the latest, the server refuses with 409 and the client saves its version as a conflicted copy. The user merges by hand, which is annoying, but nothing is lost, and conflicts are rare: they need two devices editing one file at the same time.
Follow-up questions
How do you restore a file to last week's version?
Revisions are lists of chunk hashes, kept for 30 days. Restoring commits a new revision with the old chunk list; no bytes move, because the chunks are still stored.
How do you sync a folder with 100,000 files to a new device?
Page through the journal from cursor 0, or start from a snapshot of the current file list plus its cursor, then download chunks in parallel with the smallest or most recent files first.
How do you handle a 50 GB file?
The same way: 50 GB ÷ 4 MB = 12,500 chunks, uploaded in parallel and resumable chunk by chunk. The revision record holds 12,500 hashes, about 400 KB, which is fine.
How does sharing work?
A shared folder is its own namespace with a members table. Each member's devices add that namespace and its cursor to their long poll.
Can you encrypt files so the server cannot read them?
Yes, by encrypting on the device, but then identical files from different users no longer produce identical chunks, so cross-user deduplication is gone, and chunk names must be keyed hashes so they do not reveal content. It is a product choice; say what it costs.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".