Design Cloud File Storage

Hardobject storage · chunking · deduplication · sync · metadata · consistency⏱ 9 min readAsked at Dropbox, Google, Microsoft, Amazon, BoxUpdated

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

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

text
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.
  • parentRev gives optimistic concurrency: the commit only succeeds if the server is still at the revision the client edited.
  • The /changes cursor turns sync into "give me everything after position X" in a per-user (or per-namespace) journal.

Data Model

sql
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

  1. The client watches the local folder, detects a change, splits the file into chunks and hashes each one.
  2. It asks the Block Service which hashes are missing and uploads only those (in parallel, resumable).
  3. It calls commit with the ordered block list. The Metadata API writes the version, bumps the file's revision and appends a journal entry in one transaction.
  4. 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.
Warning

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.

  1. Device A commits with parentRev = 41 and succeeds; the file is now rev 42.
  2. Device B later commits with parentRev = 41; the server returns 409 Conflict.
  3. Device B keeps the server's version as plan.docx and saves its own as plan (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.

Insight

"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_rev with a conditional check (WHERE latest_rev = parentRev), append journal row with seq = 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 (PUT by 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.

References

My notes saved in this browser