Design a File Storage & Sync Service
Dropbox / Google Drive System Design
Overview
A file storage and sync service lets a user keep files in the cloud and have them appear, up to date, on every device they own — like Dropbox, Google Drive, or OneDrive. You drop a file into a folder on your laptop; seconds later it shows up on your phone and your work machine. The product feels simple, but two properties make it interesting: it must never lose a file (durability), and it must not re-send data that hasn't changed (efficient sync). Re-uploading a 2 GB video because you renamed it, or re-downloading a whole document because one paragraph changed, would waste enormous bandwidth and storage.
It is labeled "Medium" because the hard parts are well understood but easy to get wrong: how you split files so you only move the bytes that changed (chunking + deduplication), how every device learns about changes quickly (a change journal + sync), and why you split the file tree away from the file bytes (metadata service vs blob storage).
In this walkthrough you'll scope it, size it, model the data, design the client contract, draw the upload and sync paths, and examine chunking + dedup, multi-device sync, and the metadata/blob split.
Functional requirements
- Upload and download files. A user can store a file and retrieve it from any device.
- Sync changes across devices. When a file changes on one device, every other device gets the new version automatically.
- Share a file or folder. A user can give another user access to a file (we keep this shallow — see out of scope).
- File history / versions. A user can see and restore previous versions of a file.
Out of scope (state it): real-time collaborative editing — two people typing in the same document at the same instant, with character-by-character merging (like Google Docs). That is a fundamentally different problem (operational transforms / CRDTs over a live document), not a file-sync problem, so we mention it and move on. We also keep rich permissions / access control lists (ACLs) shallow — folder-level sharing is in scope, but deep per-field permission models are their own subsystem.
Tip
Lead with the functional requirements (upload, download, sync, share, history), then layer in the non-functional ones (durability, efficient sync). Stating what's out of scope early — especially collaborative editing — keeps you from spending interview time on the wrong problem.
Non-functional requirements
- Durability — never lose a file. This is the headline. A file the user trusted us with must survive disk failures, machine failures, and even a data-center outage. We target very high durability (the industry shorthand is "eleven nines," meaning the yearly probability of losing an object is vanishingly small) by replicating each stored byte across multiple machines and locations.
- Efficient sync. Changing one paragraph of a document must transfer roughly one paragraph's worth of data, not the whole file. Renaming or moving a file must transfer no file bytes at all — only bookkeeping. This requirement is what forces chunking and dedup.
- Reasonable consistency across devices. Devices converge to the same state quickly (seconds), but they do not have to be in lockstep at every instant. The file tree and version pointers are strongly consistent at the server; the propagation to each device is eventually consistent.
- Scale. Many users, each with many files, totaling a very large amount of storage, plus a large number of always-connected clients waiting for changes.
Estimations
State assumptions; the goal is to justify chunking, dedup, and the metadata/blob split — not to be exact.
Rounded assumptions
- Users: 500M total, 100M daily active.
- Files per user: ~100K files averaging ~1 MB ⇒ ~100 GB stored per user (before dedup).
- Total stored: 500M × 100 GB = ~50 exabytes (EB) raw. Dedup and compression cut the physical footprint substantially (identical OS files, shared documents, re-uploaded photos), but it is still enormous — this is a blob-storage-scale problem.
- Devices: each active user has ~3 devices, so ~300M clients may be waiting for change notifications.
- Upload throughput: 100M DAU × ~50 changes/day ≈ ~5B changes/day ≈ ~60K/sec average, low-hundreds-of-K/sec at peak — still large; most changes touch a small fraction of a file's chunks.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| Tens of exabytes of bytes | Store bytes in cheap, durable blob storage, not a database. |
| Files are large but edits are small | Chunk files and transfer only changed chunks. |
| Lots of identical data across users/versions | Deduplicate by content hash — store each unique chunk once. |
| Small, structured file-tree/version data | Keep metadata in a strongly consistent database, separate from bytes. |
| ~300M clients waiting for changes | Don't have every client poll constantly — use a change journal + notification. |
| Rename/move must cost ~0 bytes | Separate the manifest (chunk list) from the bytes, so moves touch only metadata. |
The two big levers fall straight out of the numbers: dedup shrinks the 50 EB physical footprint, and chunk-level transfer shrinks the hundreds-of-K-changes/sec bandwidth. Both depend on chunking, which is why the first deep dive is chunking.
Note
The two halves have opposite needs. Metadata is tiny but precious — lose it and files can't be found or reassembled — so it demands a strongly-consistent database. Bytes are enormous but content-addressed and replicated, so no single copy is special; they belong in cheap, durable blob storage. The whole design is a small consistent bookkeeping layer over a vast dumb byte store — everything else (chunking, dedup, manifests, change journal) follows from that split. Deep dive 3 justifies it in full.
Core entities / data model
As above, the model keeps bookkeeping separate from bytes — here is what each side holds:
| Entity | Key fields |
|---|---|
User |
user_id (PK), email, storage quota, used bytes |
File |
file_id (PK), user_id, path/name, current_version_id, is_folder |
FileVersion |
version_id (PK), file_id, manifest_id, size, created_at, author_device |
Chunk |
chunk_hash (PK — content hash), size, blob_location, refcount |
Manifest |
manifest_id (PK), ordered list of chunk_hash (the file's recipe) |
Device |
device_id (PK), user_id, last_seen, sync_cursor |
ChangeLog |
seq (PK, per user), user_id, file_id, version_id, op, timestamp |
The heart of the model is content-addressed chunks. A content-addressed chunk's id is the hash of its bytes (e.g. SHA-256), so two chunks with identical content always get the same chunk_hash — and we store that content exactly once. The Chunk row records where the bytes live in blob storage and how many manifests reference it (refcount), so we never delete bytes that some version still needs.
A File points at its current_version_id; each FileVersion points at a Manifest; a Manifest is just an ordered list of chunk hashes — the recipe that says "to rebuild this file, concatenate these chunks in this order." Because the manifest holds hashes, not bytes, a new version that changed one chunk reuses every other chunk hash unchanged.
The ChangeLog is a per-user append-only journal: every committed change appends one row with a monotonically increasing seq — including deletes, which append a delete op and decrement the refcounts of the chunks the removed version referenced. Each Device stores a cursor (the last seq it has seen); syncing is just "give me everything after my cursor." Deep dive 2 builds on this.
Important
Keep File/FileVersion/Manifest/Chunk/ChangeLog (the metadata service, strongly consistent) separate from the chunk bytes (blob storage, cheap and durable). Mixing the file tree into the byte store, or the bytes into the database, is the most common structural mistake here.
API design
The client is not a thin caller of REST endpoints — it does real work: it splits the file into chunks, hashes them, and uploads only the chunks the server is missing. The contract has three parts: an upload handshake, chunk download, and a sync/changes endpoint. Label the request Body: and the response Returns:.
Check which chunks are missing (upload handshake)
POST /api/files/{file_id}/versions:prepare
Body: { "chunk_hashes": ["a1b2...", "c3d4...", "e5f6..."], "path": "/docs/report.pdf" }
200 OK
Returns: { "missing_chunks": ["c3d4..."], "upload_urls": { "c3d4...": "https://blob/..." } }
The client has already chunked and hashed the file locally. It sends the list of chunk hashes (not the bytes). The server checks which of those hashes already exist in the strongly-consistent Chunk index (keyed by hash) — the authoritative record of chunk existence, not blob storage — and returns only the missing_chunks — the ones it doesn't have. This is where deduplication saves bandwidth: if you re-upload a file that already exists, missing_chunks is empty and you transfer zero bytes. Note that this response leaks chunk existence across users (a privacy issue — see deep dive 1), so the per-user-dedup / proof-of-possession mitigation changes this handshake itself — a per-user chunk index or a challenge-response step — not just a policy toggle.
Upload a missing chunk
PUT <upload_url> (one per missing chunk, can run in parallel)
Body: <raw chunk bytes>
200 OK
Returns: { "chunk_hash": "c3d4...", "stored": true }
Upload only the missing chunks, directly to blob storage via the pre-signed URL. Uploads are idempotent and safe to retry: because each chunk is addressed by its hash, a re-PUT after a dropped connection restarts that interrupted chunk from the start, and already-stored chunks are skipped.
Commit the new version
POST /api/files/{file_id}/versions:commit
Body: { "manifest": ["a1b2...", "c3d4...", "e5f6..."], "base_version_id": "v41" }
201 Created
Returns: { "version_id": "v42", "current": true } (or 409 Conflict if base_version is stale)
Once all chunks are safely in blob storage, the client commits the manifest (the ordered chunk list) as a new FileVersion. The server verifies every referenced chunk exists, writes the version, advances File.current_version_id, and appends a ChangeLog row — all in one transaction. base_version_id lets the server detect a conflict (deep dive 2). This commit is the atomic moment the file "becomes" the new version.
Download a file
GET /api/files/{file_id} → returns the manifest (chunk list) for the current version
GET <chunk_url> → returns raw bytes for one chunk (one call per chunk)
Download is the mirror of upload: fetch the manifest, then fetch each chunk you don't already have locally, and reassemble. A device that already has most chunks (from a previous version) downloads only the new ones.
Sync — get changes since a cursor
GET /api/sync/changes?since={cursor}
200 OK
Returns: { "changes": [ { "file_id": "...", "version_id": "...", "op": "update", "seq": 5012 }, ... ],
"next_cursor": 5012 }
This is the engine of multi-device sync. A device asks "what changed since seq X?" and gets the list of changes plus a new cursor. It then downloads the new manifests and the missing chunks. The connection is typically a long-poll — the server holds the request open until there is a change or a timeout — so devices learn of updates within seconds without hammering the server (deep dive 2).
High-level architecture
Two flows matter: an upload path (chunk → find missing → upload → commit) and a sync path (other devices learn of the change → fetch manifest → download changed chunks). We'll walk each, then combine them.
1. Upload path
The uploading client does the chunking and hashing; the server only stores missing chunks and commits the manifest.
flowchart LR
Client[Client] -->|"1. chunk + hash locally"| Client
Client -->|"2. send chunk hashes"| Meta[Metadata Service]
Meta -->|"3. which chunks am I missing?"| Client
Client -->|"4. upload only missing chunks"| Blob[(Blob Storage)]
Client -->|"5. commit manifest + version"| Meta
Meta -->|"6. append change to journal"| Journal[(Change Journal)]
- The client splits the file into chunks and computes a content hash for each (deep dive 1).
- It sends the list of chunk hashes to the metadata service (not the bytes yet).
- The metadata service replies with the subset of chunks it does not already have — dedup in action.
- The client uploads only those missing chunks straight to blob storage.
- Once the bytes are safely stored, the client commits the manifest; the metadata service writes a new
FileVersionand advances the file's current version in one transaction. - That same commit appends a row to the per-user change journal, which is what wakes up the other devices.
Caution
Commit the version only after every chunk is durably in blob storage. If you record a manifest that points at a chunk you never finished uploading, the file is corrupt and unrecoverable — a metadata pointer to missing bytes. Bytes first, manifest second, always.
2. Sync path
Other devices learn of the new version through the change journal and download only what they're missing.
flowchart LR
Journal[(Change Journal)] -->|"1. new change for user"| Notify[Notification Service]
Notify -->|"2. long-poll wakes device"| Device[Other Device]
Device -->|"3. GET changes since cursor"| Meta[Metadata Service]
Meta -->|"4. new manifest + version"| Device
Device -->|"5. download only changed chunks"| Blob[(Blob Storage)]
- The commit appended a change to the user's journal.
- The notification service has a long-poll open from each of the user's other devices; the new change releases that held request (or pushes a lightweight "you have changes" ping).
- The woken device calls the sync endpoint with its cursor and gets the list of changes.
- It fetches the new manifest for the changed file.
- It compares the new manifest against the chunks it already has locally and downloads only the chunks that are new — usually one or two, not the whole file.
Tip
The notification service exists so 300M devices don't poll the database every few seconds. The journal records that something changed; the long-poll delivers it cheaply; the device then pulls the details. Push the small signal, pull the large data.
3. Combined architecture
Putting it together — the metadata service is the strongly-consistent hub, blob storage holds the bytes, and the journal + notification service fan changes out to devices.
flowchart LR
Up[Uploading Device] -->|"chunk hashes"| Meta[Metadata Service]
Meta -->|"missing chunks"| Up
Up -->|"upload missing chunks"| Blob[(Blob Storage)]
Up -->|"commit manifest"| Meta
Meta -->|"versions, manifests, file tree"| MetaDB[(Metadata DB - strongly consistent)]
Meta -->|"append change"| Journal[(Per-user Change Journal)]
Journal --> Notify[Notification Service]
Notify -.->|"long-poll: you have changes"| Down[Other Devices]
Down -->|"GET changes since cursor"| Meta
Down -->|"download changed chunks"| Blob
The metadata service owns the small, structured, strongly-consistent data (file tree, versions, manifests, chunk index, change journal) in a database; blob storage owns the huge, durable, content-addressed bytes. The two are deliberately separated so a rename touches only the metadata DB and a 2 GB upload touches only blob storage. The journal + notification service turn a single commit into a fan-out to every other device without anyone polling. Deep dive 3 covers why this split is the right shape and how the commit stays atomic across it.
Deep dives
1. Chunking and deduplication
The goal: when a file changes a little, transfer a little; when content repeats across files, versions, or users, store it once. Both come from one idea — split each file into chunks, give each chunk an id that is the hash of its bytes (content-addressed), and store each unique id exactly once (dedup).
Note
This works for any file type. Whatever the file — a text document, a JPEG photo, an MP4 video, a PDF — on disk it is just one ordered sequence of bytes. The chunker never looks inside those bytes or interprets the format; it treats every chunk as an opaque byte range, splits on byte boundaries, and hashes the bytes. So the same split → hash → store mechanism covers the entire product, from a 2 KB note to a 4 GB video — the file's type lives only in the viewer that later reads it, never in the storage layer.
The open question is how you choose the chunk boundaries, because that decides how well small edits localize. Two approaches follow, but first a word on how big a chunk should be. Tiny chunks dedup finely but explode metadata — more hashes per file, bigger manifests, more chunk-index rows — while huge chunks shrink metadata but waste transfer, since any edit re-sends a whole large chunk. Real systems target a middle size (around 4 MB). Content-defined chunking also needs an explicit min/max bound on chunk size: without a minimum the rolling hash can emit a 1-byte chunk on an unlucky byte, and without a maximum it can run far past the target before the boundary pattern happens to match.
Fixed-size chunking — cut every N bytes (works, with caveats)
Split the file at fixed offsets — e.g. a new chunk every 4 MB. Simple: chunk k is always bytes [k×4MB, (k+1)×4MB).
Worked example — a 12 MB file, 4 MB chunks, then you insert 1 byte near the start:
Before: [chunk A: 0–4MB][chunk B: 4–8MB][chunk C: 8–12MB]
Insert 1 byte at offset 100:
After: [A': shifted][B': shifted][C': shifted][tiny tail]
every boundary moved by 1 byte → every chunk's bytes differ
→ all 3 chunks get new hashes → re-upload the WHOLE file
- Pro: trivial to implement; boundaries are pure arithmetic, no scanning.
- Con: an insert or delete near the start shifts every later boundary (the "boundary-shift problem"), so every downstream chunk changes its bytes, gets a new hash, and must be re-uploaded — dedup collapses for edited files.
- Verdict: works, with caveats — fine for append-only or rarely-edited blobs (photos, videos), but it defeats efficient sync for files that get edited in the middle.
Content-defined chunking — cut where the content says to (recommended)
Choose boundaries based on the bytes themselves, not on byte offsets. Slide a small window across the file; wherever a cheap hash of the window matches a pattern (e.g. its low bits are all zero), declare a chunk boundary (this is done with a rolling hash). Because boundaries are tied to local content, inserting bytes only disturbs the chunk(s) around the edit — the boundaries before and after it land in the same places.
Worked example — same 12 MB file, same 1-byte insert:
Before: [A | B | C] boundaries chosen by content pattern
Insert 1 byte inside B:
After: [A | B' | C] A and C hash the same → already stored
only B' is new → upload ONE chunk, not the whole file
- Pro: edits stay local — an insert/delete only re-chunks the region around it, so a small edit re-uploads ~one chunk. Dedup survives across versions and across files that share content.
- Con: more complex (rolling hash, variable chunk sizes); boundaries aren't predictable, so chunk count varies. Slightly more CPU on the client to scan for boundaries.
- Verdict: the right default for a sync product, because "edit a little, transfer a little" is the whole point. The extra client CPU is cheap next to the bandwidth saved.
Putting dedup together. Whichever boundary method you use, the dedup logic is the same: hash each chunk, ask the server which hashes it lacks, upload only those. Here is the small-edit case end to end, which is the example interviewers want to hear:
report.pdf v41 manifest: [h_A, h_B, h_C, h_D] (4 chunks)
You edit one paragraph in the middle → re-chunk → [h_A, h_B, h_X, h_D]
client asks server: "do you have h_A, h_B, h_X, h_D?"
server: "missing only h_X"
client uploads h_X (one chunk), commits manifest [h_A, h_B, h_X, h_D] as v42
→ a multi-MB file, one paragraph changed, ~one chunk transferred
This also gives resumable upload, but per chunk, not within a chunk: if the connection drops after uploading two of five missing chunks, the next attempt re-asks "which are missing?" and gets the remaining three — already-stored chunks are skipped because they're addressed by content, while the chunk that was interrupted restarts from its beginning. Keeping chunks bounded (~4 MB) caps how much a restart re-transfers; for a very large single chunk you can fall back to a multipart upload that resumes mid-chunk. And because the same h_X would be produced by any user whose file contains that content, identical chunks across users are stored once too (subject to the privacy note below).
Warning
Cross-user deduplication has a privacy and security edge: if you tell user B "we already have that chunk, don't upload it," B can learn that someone already stored that exact content (a confirmation-of-file attack). Real systems mitigate this — e.g. dedup per-user, or only after proving possession of the full content. Mention the tradeoff; don't blindly dedup every user's bytes together.
Caution
Do not use the content hash as a security boundary by itself. Use a strong hash (SHA-256, not MD5) so two different chunks can't collide to the same id — a collision would let one file's bytes silently overwrite another's. With a strong hash the probability is negligible, but naming the requirement matters.
2. Sync across devices
Every other device must converge on the new version quickly. Two questions: how a device learns there's a change, and what happens when two devices edit the same file offline.
Detecting and propagating changes — the journal + cursor. Each commit appends one row to the user's append-only change journal with an increasing seq. The seq is allocated inside the same per-user commit transaction that advances current_version_id, so it is strictly ordered per user; because the journal is sharded by user_id (the same metadata shard key), one user's commits serialize on their own shard and never contend with another user's. Each device remembers the last seq it processed (its cursor). Sync is then "give me everything after my cursor," which is cheap and delivers at-least-once with idempotent apply (effectively-once): the device advances its cursor only after it has applied a change, so a crash mid-sync just re-fetches from the old cursor and re-applying the same content-addressed change is a no-op. A brand-new device has no cursor, so it seeds from a full tree snapshot and then switches to cursor-based incremental sync (closing the initial-sync gap). The remaining question is how the device finds out a new row exists.
Client polling — ask on a timer (works, with caveats)
Each device calls the sync endpoint every few seconds: "anything after my cursor?" Most calls return an empty list.
Worked example — 300M devices, poll every 5s:
300M devices / 5s ≈ 60M requests/sec just to ask "anything new?"
~99% of those return "nothing changed" → wasted load
a change made at t=0 is seen up to 5s later (worst case = poll interval)
- Pro: dead simple; no long-lived connections; stateless servers.
- Con: enormous wasted load at this scale (tens of millions of empty requests/sec), and a built-in delay equal to the poll interval. Polling faster lowers latency but multiplies the waste.
- Verdict: works, with caveats — acceptable for small fleets or low-urgency sync; too wasteful as the only mechanism at 300M devices.
Long-poll / push notification — wait to be told (recommended)
The device opens a request to the notification service that the server holds open until there is a change for that user (or a ~30s timeout, then the client reconnects). When a commit appends to the journal, the notification service releases the held requests for that user's other devices, which then pull the change via the normal sync endpoint.
Worked example — same fleet, long-poll:
device opens long-poll → server holds it (no traffic while idle)
commit at t=0 → notification service signals that user's devices
held requests return within ~milliseconds → device calls /sync/changes
→ change seen in well under 1s, with ~0 traffic during idle periods
- Pro: near-instant propagation and almost no traffic while nothing changes — you pay only when there's real news. Scales to huge idle fleets.
- Con: the server holds ~300M open connections (needs connection-oriented, horizontally scaled notification servers); more operational complexity than stateless polling.
- Verdict: the production default for a sync product. Keep the channel lightweight — it carries only "you have changes," and the device pulls the details.
Conflict handling — two devices edit offline. If two devices both edit report.pdf while offline and then sync, both try to commit a new version on top of the same base_version_id. The server accepts the first and rejects the second with 409 Conflict (the second's base is now stale). You then resolve:
- First-commit-wins + conflicted copy for the loser (recommended for files). Keep the version that committed first as the file's current version, and save the loser as a conflicted copy —
report (Alice's conflicted copy).pdf. No data is lost; the user decides what to keep. This is what Dropbox does, and it's the right answer for opaque files you can't auto-merge. - Versioning. Because every commit is a new
FileVersionpointing at a manifest, you keep full history cheaply (versions share unchanged chunks). The user can restore any prior version. File history and conflict resolution reuse the same versioned manifest model.
Warning
Do not try to byte-merge two conflicting versions of an arbitrary file automatically. You can't merge a binary (a .psd, a .zip) the way you can merge text. First-commit-wins plus a clearly-named conflicted copy is safe and predictable; silent auto-merge corrupts files.
Caution
Do not let a stale device overwrite a newer version blindly. The base_version_id check on commit is what catches the conflict — without it, the second device's commit would clobber the first's edit and lose data.
3. Metadata vs blob separation and consistency
Why split the system into a metadata service and blob storage at all? Because the two kinds of data have opposite needs:
- The file tree, versions, and manifests are small, highly structured, frequently queried ("what's the current version? what changed since my cursor?"), and must be strongly consistent — a device must never see a manifest that points at a version that doesn't exist. This belongs in a database (relational or a strongly-consistent distributed store), where you get transactions and indexes.
- The chunk bytes are huge, opaque, write-once (content-addressed bytes never change — a different byte sequence is a different chunk), and need cheap, massively durable storage. This is exactly what blob (object) storage is built for: replicate every object across machines and locations for very high durability, at low cost per byte, addressed by an id.
Putting bytes in the database would blow up its size and cost and ruin its performance; putting the file tree in blob storage would lose transactions, indexes, and consistency. The split lets each side scale on its own axis — metadata on query throughput and consistency, blobs on raw capacity and durability.
Committing a version atomically across the split. The danger in a two-store design is a half-finished write: bytes in blob storage with no manifest, or a manifest pointing at bytes that aren't there. The ordering rule fixes it:
1. Upload all missing chunks to blob storage (bytes are now durable)
2. Verify every chunk in the manifest exists (server-side check)
3. In ONE metadata transaction:
- write the new FileVersion + Manifest
- advance File.current_version_id
- increment refcount for each chunk the new manifest references
- append the ChangeLog row (increment seq)
→ the file "becomes" the new version atomically
That same transaction maintains Chunk.refcount: committing a version increments the refcount of every chunk its manifest references, and deleting or superseding a version decrements them. Bytes first means an interrupted upload leaves only orphan chunks (bytes nobody references), never a manifest pointing at missing bytes. Orphans are harmless and cheap to garbage-collect later (a background sweep deletes chunks whose refcount is zero). The manifest commit is a single metadata transaction, so a device either sees the whole new version or the old one — never a torn state. This is why the file tree must be strongly consistent while the bytes can be eventually-consistent durable copies: chunk existence is tracked in the strongly-consistent Chunk index (keyed by hash), so a commit can authoritatively verify its chunks; eventual consistency applies only to the cross-region byte replication that happens after that authoritative index row exists. The manifest is the single source of truth for "what is this file right now," and it flips atomically.
Caution
GC must not race the upload handshake. When prepare tells a client "I already have this chunk, don't upload it," that chunk currently has no manifest referencing the in-flight version, so a naïve "delete refcount==0" sweep could collect it between that reply and the client's commit — corrupting the new version. Mitigate by giving GC a grace period (never collect chunks younger than some T, or recently referenced by a prepare) or by a check-and-hold so an in-flight prepare protects the chunk until its commit lands or expires.
Note
The metadata service is strongly consistent; propagation to devices is eventually consistent. The server always knows the one true current version (strong consistency at the hub); each device catches up to it within seconds via the journal (eventual consistency at the edges). Naming this split — strong at the center, eventual at the edges — is exactly the consistency answer the interviewer wants.
Tradeoffs & bottlenecks
- Metadata vs blob split. Two stores add operational complexity and a careful commit ordering, but they let bytes and bookkeeping scale independently and durably. This is the central structural tradeoff, and the right one.
- Content-defined vs fixed-size chunking. Content-defined chunking localizes edits (small edit → small transfer) at the cost of client CPU and variable chunk sizes; fixed-size is simpler but loses dedup the moment you insert or delete in the middle. Choose content-defined for an edit-heavy sync product.
- Chunk size. Small chunks dedup finely and transfer minimal data per edit, but multiply metadata (more hashes per file, bigger manifests, more index entries). Large chunks shrink metadata but transfer more per edit. Real systems pick a middle target (e.g. ~4 MB) with a min/max bound.
- Long-poll/push vs polling. Push gives near-instant sync with almost no idle traffic but requires holding hundreds of millions of connections; polling is stateless and simple but wasteful and laggy at scale.
- Cross-user dedup vs privacy. Global dedup maximizes storage savings but leaks "this content already exists" and needs proof-of-possession mitigations. Per-user dedup is safer but saves less.
- Strong metadata consistency vs availability. The metadata hub favors consistency (one true current version), which is the right call — but it makes that hub the thing you must scale and keep highly available (sharding by
user_id, replication). The blob tier, being content-addressed and immutable, is easy to replicate widely.
Extensions if asked
If the interviewer wants to push beyond the core sync flow, add only the extension that changes the design discussion. (These are standalone topics; there's no dedicated guide for them yet.)
- Sharing and permissions (ACLs). Folder- and file-level sharing with roles (viewer/editor), link sharing with expiry, and how a shared folder appears in multiple users' trees without duplicating bytes (the manifest/chunks are shared; only the tree entry is added per user). Deep permission models are their own subsystem.
- Real-time collaborative editing. Character-by-character co-editing of a live document (operational transforms or CRDTs). This is a different problem from file sync — it operates on a structured document model, not opaque chunks — and is worth flagging as out of scope unless the interviewer specifically wants it.
- Search over file contents. Indexing file names and full text into a search engine, kept in sync from the change journal, so users can find files by content — separate from the storage core.
- End-to-end encryption. Encrypting chunks on the client so the server never sees plaintext. This largely breaks cross-user dedup (identical content encrypts differently per user/key) — a direct tension worth naming.
- Selective sync and LAN sync. Letting a device sync only some folders, or pull chunks from a peer on the same network instead of the cloud, to save bandwidth.
What interviewers look for & common mistakes
What interviewers usually reward:
- Chunking + content-addressed dedup as the mechanism for efficient sync — and naming why fixed-size chunking breaks on mid-file edits while content-defined chunking keeps edits local.
- The upload handshake — client sends chunk hashes, server returns only the missing ones — so re-uploads and small edits transfer minimal bytes, and uploads resume cheaply per chunk (a dropped connection re-asks which chunks are still missing rather than restarting the whole file).
- The metadata vs blob split, with the file tree/manifests in a strongly consistent store and the bytes in cheap durable blob storage, plus the bytes-first commit ordering that keeps versions atomic.
- A change journal + cursor + long-poll/push for multi-device sync, instead of having every device poll constantly.
- Conflict handling — first-commit-wins with a conflicted copy for the loser (not silent auto-merge), backed by the versioned manifest model.
Before you finish, do a quick mistake check:
- Did you chunk files and transfer only changed/missing chunks, rather than whole files?
- Did you make chunks content-addressed so dedup is automatic?
- Did you separate the metadata service from blob storage, and explain why?
- Did you commit the manifest only after chunks are durably stored (bytes first)?
- Did you use a change journal + cursor + notification for sync instead of constant polling?
- Did you handle offline conflicts with first-commit-wins + conflicted copy for the loser, not byte-level auto-merge?
- Did you name the cross-user dedup privacy tradeoff?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →