Design Cloud File Storage
Design a Dropbox/Google Drive-style service that stores files as deduplicated chunks, keeps many devices in sync, and resolves conflicting edits safely.
Requirements
Users drop files into a folder on their laptop and expect them to appear on their phone, their other laptop and the web within seconds. They also expect never to lose a file, even when two devices edit it offline.
Functional
- Upload, download, rename, move and delete files and folders.
- Automatic sync across all of a user's devices, including after offline periods.
- File version history and restore (e.g. last 30 days).
- Share files or folders with other users (view / edit).
- Notifications when a shared file changes.
Non-functional
- Durability above everything: target 11 nines for stored bytes; losing data is unacceptable.
- High availability for metadata reads and sync (99.9%+).
- Bandwidth efficiency: editing 1 KB of a 1 GB file must not re-upload 1 GB.
- Strong consistency for metadata: a folder listing must never show a file that does not exist or hide one that does.
- Scalable to hundreds of millions of users and exabytes of data.
Ask early whether real-time collaborative editing (Google Docs style) is in scope. It usually is not: file sync is whole-file versioning with conflict copies, which is a very different problem from operational transforms or CRDTs.
Capacity Estimates
Assume 500 M registered users, 100 M daily active, 15 GB average stored per user, and each active user changes ~2 files per day with an average file size of 1 MB.
| Metric | Math | Value |
|---|---|---|
| Logical storage | 500 M × 15 GB | ~7.5 EB |
| Physical after dedup (~30% saving) | 7.5 EB × 0.7 | ~5 EB (before replication / erasure coding) |
| File changes per day | 100 M × 2 | 200 M |
| Write QPS (file commits) | 200 M / 86,400 | ~2,300 /s (peak ~7,000 /s) |
| Upload bandwidth | 200 M × 1 MB / 86,400 | ~2.3 GB/s average |
| Metadata reads (sync polls, listings) | ~10× writes | ~25 K /s |
| Chunks per day (4 MB chunks) | ≥1 chunk per change | ~200 M+ chunk writes |
| Metadata rows | 500 M users × ~5,000 files | ~2.5 T file rows |
Two different systems emerge: a block store that is huge but simple (immutable blobs keyed by hash) and a metadata store that is smaller but needs transactions and strong consistency.
API Design
POST /v1/blocks/check
Body: { "hashes": ["sha256:ab12...", "sha256:cd34...", ...] }
200: { "missing": ["sha256:cd34..."] } // only these need uploading
PUT /v1/blocks/{hash} // raw chunk bytes, idempotent
201 | 200 (already present)
POST /v1/files/commit
Body: { "path": "/docs/plan.pdf", "blocks": ["ab12...", "cd34..."],
"size": 7340032, "parentRev": 41 }
200: { "fileId": "f_9x", "rev": 42 }
409: { "conflict": true, "serverRev": 43 } // someone else changed it
GET /v1/files/{fileId}/content?rev=42 // or block list + presigned URLs
GET /v1/changes?cursor=abc123 // long-poll for deltas since cursor
200: { "entries": [...], "cursor": "abc124", "hasMore": false }
POST /v1/shares { "path": "/docs", "user": "bob@x.com", "role": "editor" }
- Uploading is split into check → upload missing blocks → commit, so a commit is a tiny metadata transaction.
parentRevgives optimistic concurrency: the commit only succeeds if the server is still at the revision the client edited.- The
/changescursor turns sync into "give me everything after position X" in a per-user (or per-namespace) journal.
Data Model
CREATE TABLE namespaces ( -- a user's root or a shared folder ns_id BIGINT PRIMARY KEY, owner_id BIGINT NOT NULL, journal_seq BIGINT NOT NULL -- latest change number ); CREATE TABLE files ( ns_id BIGINT, file_id BIGINT, path TEXT NOT NULL, is_dir BOOLEAN, latest_rev BIGINT, is_deleted BOOLEAN DEFAULT FALSE, PRIMARY KEY (ns_id, file_id) ); CREATE TABLE file_versions ( file_id BIGINT, rev BIGINT, block_list TEXT, -- ordered list of chunk hashes size_bytes BIGINT, modified_by BIGINT, created_at TIMESTAMP, PRIMARY KEY (file_id, rev) ); CREATE TABLE journal ( -- append-only change log per namespace ns_id BIGINT, seq BIGINT, file_id BIGINT, rev BIGINT, op VARCHAR(10), -- ADD / EDIT / DELETE / MOVE PRIMARY KEY (ns_id, seq) );
| Store | Contents | Partition key |
|---|---|---|
| Metadata DB (sharded MySQL / Spanner-like) | namespaces, files, versions, journal, ACLs | ns_id |
| Block index (KV) | hash → storage location, ref count | hash |
| Block store (S3 / custom object store) | immutable chunk bytes | hash |
| Cache (Redis) | hot listings, block existence, session state | ns_id / hash |
Sharding by namespace keeps every operation on one folder tree inside one shard, so a commit (new version + file row + journal entry) is a single local transaction.
High-Level Design
flowchart LR Client[Desktop / Mobile Client] --> LB[Load Balancer] LB --> API[Metadata API] LB --> BS[Block Service] Client <-->|long-poll / websocket| NS[Notification Service] API --> MDB[(Metadata DB sharded by namespace)] API --> MC[(Metadata Cache)] API --> Q[[Change Events Queue]] Q --> NS BS --> BI[(Block Index)] BS --> OBJ[(Object Store)] OBJ --> CDN[CDN for downloads]
Upload path
- The client watches the local folder, detects a change, splits the file into chunks and hashes each one.
- It asks the Block Service which hashes are missing and uploads only those (in parallel, resumable).
- It calls
commitwith the ordered block list. The Metadata API writes the version, bumps the file's revision and appends a journal entry in one transaction. - A change event is published; the Notification Service wakes every other device subscribed to that namespace.
Download path: a woken client calls /changes with its cursor, receives new versions, diffs block lists against its local block cache, and fetches only missing chunks (often via presigned CDN URLs).
Deep Dives
Chunking and deduplication
Files are stored as a list of chunks, each addressed by its SHA-256 hash (content-addressable storage).
- Fixed-size chunks (e.g. 4 MB) are simple and fast. Their weakness: inserting one byte at the start shifts every boundary, so every chunk hash changes.
- Content-defined chunking (CDC) uses a rolling hash (Rabin fingerprint, FastCDC) and cuts a chunk wherever the hash matches a pattern. Boundaries follow content, so an insertion only changes the one or two chunks around it.
- Dedup falls out naturally: if a hash already exists in the block index, the bytes are never uploaded or stored again. This works across versions of a file, across files, and (if allowed) across users.
- Garbage collection keeps a reference count per block (or runs mark-and-sweep over all live version block lists). Blocks are only deleted after version history expires and a grace period passes.
Cross-user dedup leaks information: if uploading a file is instantly "done", an attacker can test whether anyone already has that exact file. Mitigations include per-user dedup only, requiring the client to prove possession of the bytes, or always accepting the upload while discarding duplicates server-side.
Sync protocol and change notification
Each namespace has an append-only journal with a monotonically increasing sequence number. A client's state is just "I have applied everything up to seq N".
- Notify, then pull: the server pushes a tiny "something changed in ns X" message over a long-poll or WebSocket; the client then pulls
/changes?cursor=N. Pushing only a hint keeps the notification tier stateless and cheap, and pulling is naturally idempotent and resumable. - Offline clients simply resume from their last cursor. If the cursor is too old (journal compacted), the client falls back to a full listing and reconciles.
- Local state on the client: a SQLite database of path → (rev, block list, local mtime, hash). On startup it rescans the folder and compares against this to find edits made while the app was closed.
- Batching: a save in an editor can generate many file-system events; the client debounces for a second or two before chunking and committing.
Conflict resolution
Two devices edit plan.docx while one is offline. Both started from rev 41.
- Device A commits with
parentRev = 41and succeeds; the file is now rev 42. - Device B later commits with
parentRev = 41; the server returns 409 Conflict. - Device B keeps the server's version as
plan.docxand saves its own asplan (B's conflicted copy 2026-10-30).docx, then commits that as a new file.
No data is lost and no silent overwrite occurs. Folder-level conflicts (delete vs edit, move vs move) follow similar rules: an edit wins over a delete, so the file is resurrected rather than lost.
"Last writer wins" by timestamp is the tempting answer, but device clocks are unreliable and it silently discards work. Optimistic concurrency with a parent revision plus conflicted copies is the industry-standard approach for file sync because it is simple and loss-free.
Metadata consistency
The metadata store is the source of truth and must be strongly consistent within a namespace.
- A commit is one ACID transaction: insert version row, update
files.latest_revwith a conditional check (WHERE latest_rev = parentRev), append journal row withseq = journal_seq + 1. - Ordering of blocks vs metadata matters: blocks first, metadata last. A commit referencing a missing block is rejected. Orphan blocks from abandoned uploads are cleaned up by GC.
- Moves and renames are metadata-only; no bytes move.
- Shared folders are their own namespace mounted into each member's tree, so one journal serves every collaborator.
Block storage and durability
- Blocks are immutable, so storage can be aggressively optimized: write once, never update in place.
- New and hot blocks are replicated (e.g. 3 copies across zones) for fast reads; older cold blocks are moved to erasure coding (e.g. 6 data + 3 parity fragments) which gives higher durability at ~1.5× overhead instead of 3×.
- Background scrubbers re-read and re-hash blocks to detect bit rot and repair from parity.
- Encrypt every block at rest; content hashes double as integrity checks on download.
Scaling & Reliability
- Metadata: shard by namespace; very large namespaces (enterprise team folders) may need splitting or a dedicated shard. Read replicas serve listings; the primary handles commits.
- Hot namespaces: a shared company folder with 50 K members creates a notification fan-out storm; batch notifications and let clients pull at jittered intervals.
- Upload resilience: chunk-level uploads are resumable and idempotent (
PUTby hash), so flaky mobile networks just retry missing chunks. - Downloads go through a CDN using short-lived signed URLs; popular shared files are served from edge caches.
- Multi-region: keep each namespace's metadata in a home region with synchronous replicas; replicate blocks asynchronously to a second region for disaster recovery.
- Client throttling: a buggy client in a sync loop can hammer the API; rate-limit per device and use exponential backoff.
Trade-offs
| Decision | Option A | Option B | Typical choice |
|---|---|---|---|
| Chunking | Fixed size (simple, fast) | Content-defined (better dedup on inserts) | CDC for desktop sync, fixed for simple APIs |
| Dedup scope | Per user (private) | Global (max savings) | Per user, or global with proof of possession |
| Change delivery | Push full deltas | Push hint, client pulls | Hint + pull |
| Conflicts | Last writer wins | Conflicted copy | Conflicted copy |
| Metadata store | NoSQL (scale) | Sharded SQL / NewSQL (transactions) | Sharded SQL / NewSQL |
| Redundancy | 3× replication (fast) | Erasure coding (cheaper) | Replicate hot, erasure-code cold |
Interview Tips
- Separate metadata and block paths early; it drives every later decision.
- Explain why chunking enables resumable uploads, delta sync and dedup in one stroke.
- Walk through a concrete conflict with revision numbers; interviewers love this.
- Describe the journal + cursor model; it makes offline sync and retries trivial.
- State the commit ordering (blocks before metadata) and how GC cleans orphans.
- Mention the privacy side-channel of global dedup to show depth.
- Keep durability numbers grounded: replication vs erasure coding and why cold data differs.