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.

Case study32 minLesson 4 of 8
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

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

The first five minutes
Interviewer

Design a file sync service, like a shared folder that follows you across devices.

You

So a folder on each device, kept in sync through the cloud. Do we need sharing between users, version history, and editing while offline?

Interviewer

Shared folders yes, offline editing yes. Keep 30 days of versions.

You

How many users, how much data each, and how often do files change?

Interviewer

10 million users, about 2 GB each on average, around 3 devices per user. Say 3 million people change something on a given day.

You

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.
Estimate: Size of the chunk index

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?

You know
  • 20 PB of stored data
  • 4 MB per chunk
  • About 100 bytes per index row

Show the working
  1. 2 × 1016 B ÷ 4 × 106 B = 5 × 109 chunks
  2. 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.

Estimate: Bytes saved by delta sync

A user changes a few cells in a 200 MB file. How many bytes does the client upload with 4 MB chunks?

You know
  • The file is 200 MB
  • The edit falls inside one 4 MB chunk
  • Unchanged chunks are already on the server

Show the working
  1. 200 MB ÷ 4 MB = 50 chunks
  2. 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)

Your turn: Define the API

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

Sync 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 again

The 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.

Your turn: Model the data

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

Metadata tables (sharded by namespace_id)
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 sha256

The 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.

Your turn: High-level design

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

Bytes go through the block service to object storage; file lists go through the metadata service. Notifications only say where to look.Laptop, phone to Metadata service (commit, changes); Laptop, phone to Notification service (long poll); Laptop, phone to Block service (chunks); Metadata service to Metadata DB; Metadata DB to Change events (journal feed), asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phoneMetadata serviceNotificationserviceBlock serviceMetadata DBChange eventsChunk storecommit, changeslong pollchunksjournal feed

request and replyasynchronous message

Bytes go through the block service to object storage; file lists go through the metadata service. Notifications only say where to look.
A save on the laptop reaches the phoneUse the arrows or ← → keys
  1. A save on the laptop reaches the phone, step 1Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phone1 of 50 changedMetadata serviceNotificationserviceBlock serviceMetadata DBChange eventsChunk store
    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.

  2. A save on the laptop reaches the phone, step 2Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phoneMetadata service412: 1 missingNotificationserviceBlock serviceMetadata DBChange eventsChunk store
    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.

  3. A save on the laptop reaches the phone, step 3Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phoneMetadata serviceNotificationserviceBlock serviceMetadata DBChange eventsChunk store+4 MB
    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.

  4. A save on the laptop reaches the phone, step 4Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phoneMetadata serviceNotificationserviceBlock serviceMetadata DBrev 8, seq 1042Change eventsChunk store
    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.

  5. A save on the laptop reaches the phone, step 5Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phoneMetadata serviceNotificationserviceBlock serviceMetadata DBChange eventsChunk store
    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.

  6. A save on the laptop reaches the phone, step 6Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phonephone: changedMetadata serviceNotificationserviceBlock serviceMetadata DBChange eventsChunk store
    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.

  7. A save on the laptop reaches the phone, step 7Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phoneMetadata serviceNotificationserviceBlock serviceMetadata DBChange eventsChunk store
    phone cursor
    1042

    7The phone asks for changes after 1041 and gets one entry: budget.xlsx at revision 8 with its 50 hashes.

  8. A save on the laptop reaches the phone, step 8Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phoneMetadata serviceNotificationserviceBlock serviceMetadata DBChange eventsChunk store
    phone cursor
    1042
    downloaded
    4 MB

    8It already holds 49 of those chunks, so it downloads one and rebuilds the file locally.

  9. A save on the laptop reaches the phone, step 9Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phoneMetadata service409: now rev 8NotificationserviceBlock serviceMetadata DBChange eventsChunk store
    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.

  10. A save on the laptop reaches the phone, step 10Laptop, phone to Metadata service; Laptop, phone to Notification service; Laptop, phone to Block service; Metadata service to Metadata DB; Metadata DB to Change events, asynchronous; Change events to Notification service, asynchronous; Block service to Chunk store.Laptop, phonepoll each 60 sMetadata serviceNotificationservicedownBlock serviceMetadata DBChange eventsChunk store
    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.

1 / 10

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).

How to cut a file into chunks
OptionGood atBad atPick it when
Whole fileSimplest; 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 defaultEasy 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 chunksBoundaries 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.

What to do when two edits collide
OptionGood atBad atPick it when
Last writer winsNo 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 defaultNothing 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 mergeNo 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 diesWhat happensWhat you do
A block service serverUploads and downloads to it fail.Clients retry on another; chunk uploads are idempotent by hash.
A metadata shard primaryCommits 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 serviceChanges reach other devices late.Devices fall back to polling every minute; nothing is lost.
The change-event queueNotifications stop.Same fallback; the journal is untouched.
A device mid-uploadSome 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

LevelWhat the answer does
JuniorUploads whole files to object storage with a table of file paths, and polls for changes.
MidSplits files into hashed chunks for delta sync, separates metadata from bytes, and adds push notifications.
SeniorCommits 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.
Defending the conflict designStrong answer
Interviewer

Why not just keep whichever version arrived last?

You

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.

Self-review: file sync0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure