Hard

Design Cloud File Storage (Dropbox/Drive)

Design a file hosting service where users upload files, sync them across devices, and share them with others. The core ideas are content-defined chunking, block-level deduplication, delta sync so edits upload only changed chunks, a metadata database that is the real brain of the system, and conflict resolution when two devices edit the same file offline.

1Requirements

Functional

  • Upload, download, and delete files up to ~10 GB from desktop, mobile, and web clients.
  • Automatic sync: a change on one device propagates to the user's other devices within seconds.
  • File version history: restore any previous version within the retention window (e.g., 30 days).
  • Share files and folders with other users with viewer/editor permissions.
  • Offline edits sync when the device reconnects, with safe conflict handling when both sides changed.

Non-functional

  • Durability is the prime directive: eleven nines via replicated/erasure-coded block storage; losing a user's file is unacceptable.
  • Sync latency of a few seconds for small edits on connected devices.
  • Bandwidth efficiency: never re-upload unchanged data; edits transfer only deltas.
  • Metadata operations strongly consistent (a committed upload is immediately listable on other devices).
  • Scale: 500M users, ~50 PB logical data; encryption at rest and in transit.

2Back-of-envelope estimation

Users and data500M users, 50 PB logical
Physical storage after dedup and erasure coding~53 PB
Upload traffic~230K chunks/s peak
Metadata size~30 TB
Notification fan-out~50M concurrent long-poll/WebSocket connections

3API design

POST /v1/files/commit

Commits a file version: {path, size, file_hash, ordered chunk hashes, parent_version}. Server responds with which chunks it already has (dedup) and presigned upload URLs for the missing ones; commit finalizes once all chunks exist.

PUT {presigned_block_url} (body: encrypted chunk)

Uploads one content-addressed chunk directly to block storage, bypassing application servers; retried independently, enabling resumable parallel uploads.

GET /v1/files/{fileId}?version=n

Returns file metadata and the chunk list with presigned download URLs; the client fetches only chunks it does not already hold locally.

GET /v1/changes?cursor={cursor} (+ long-poll /v1/notify)

Cursor-based journal of metadata changes for the account; the notify channel just says 'something changed', and the client then pulls the delta from its cursor. This pull-based delta model makes missed notifications harmless.

4High-level design

The desktop client is a significant system component in its own right: a watcher detects local file changes, a chunker splits files into blocks and hashes each (SHA-256), a local SQLite index maps files to chunk hashes, and a sync engine reconciles local state against the server journal. Because chunks are content-addressed, 'what changed' is computable entirely from hashes.

On upload, the client sends the chunk-hash manifest to the metadata service first. The server diffs it against known chunks and returns only the missing ones, so unchanged chunks (the common case for edits) and chunks any user already uploaded (dedup) are never transferred. Missing chunks go straight to block storage (S3-style) via presigned URLs, keeping bulk bytes off the application tier.

Once all chunks are durable, the client commits the version. The metadata service transactionally writes the new file version, its ordered chunk list, and a journal entry, with an atomic parent-version check that is the linchpin of conflict detection. Metadata lives in a sharded relational database (sharded by user/namespace) because sync correctness leans hard on transactions.

The journal entry triggers the notification service, which pings the user's other online devices over long-lived connections. Devices respond by pulling changes from their cursor, computing which chunks they lack, and downloading just those, assembling the new version locally. Shared folders work the same way with the namespace's journal fanned out to all members.

Cold chunks tier from hot object storage to cheaper archival classes based on access recency, and block storage runs erasure coding across failure domains for durability at ~1.5x overhead. A garbage collector deletes chunks only when reference counts from all live versions and the retention window reach zero.

5Data model

file_version

id BIGINT PK, file_id BIGINT, version INT, size BIGINT, file_hash CHAR(64), device_id BIGINT, committed_at TIMESTAMP, is_deleted BOOLEAN, UNIQUE (file_id, version)

Immutable once committed; version history and rollback are just pointers to old rows.

version_chunk

version_id BIGINT, seq INT, chunk_hash CHAR(64), chunk_size INT, PK (version_id, seq)

Ordered manifest mapping a version to its chunks; the join table that makes dedup and delta sync possible.

chunk

chunk_hash CHAR(64) PK, storage_key VARCHAR, ref_count BIGINT, size INT, created_at TIMESTAMP

Content-addressed; ref_count guards GC. In practice ref counting is done via periodic mark-and-sweep jobs rather than synchronous counters.

journal

namespace_id BIGINT, cursor BIGINT, file_id BIGINT, version_id BIGINT, op VARCHAR, ts TIMESTAMP, PK (namespace_id, cursor)

Monotonic per-namespace change log; clients sync by cursor, which makes recovery after disconnection trivial.

6Deep dives

Chunking strategy: fixed-size vs. content-defined

Fixed-size chunking (e.g., 4 MB blocks, Dropbox's historical choice) is simple and fast: offsets are predictable, and an in-place edit dirties only the chunks it touches. Its weakness is the insertion problem: inserting one byte near the start of a file shifts every subsequent byte, changing every downstream chunk hash and forcing a near-full re-upload.

Content-defined chunking (CDC) fixes this by cutting chunks where a rolling hash (Rabin fingerprint or Gear/FastCDC) of a sliding window hits a boundary pattern, with min/avg/max bounds like 2/4/8 MB. Boundaries are determined by content, so an insertion changes only the chunk containing it (and occasionally a neighbor); everything after re-aligns to the same boundaries. The costs are CPU on the client and variable chunk sizes complicating bookkeeping.

A sensible answer: fixed 4 MB for the v1 because most real workloads are whole-file replacements or appends, then CDC as the optimization for large frequently-edited files where it shines (VM images, mail archives, design files). Also note dedup granularity interacts with chunk size: smaller chunks dedup better but explode metadata row count, and at 12.5B chunk rows metadata is already the scaling pressure point.

Deduplication and its security tradeoffs

Content addressing gives dedup almost for free: before uploading, the client sends chunk hashes and the server answers 'already have these'. Within one account this makes copies and moves nearly instant. Across users the savings are large (~30% is a commonly cited figure) because popular files (installers, media, shared docs) are stored once regardless of how many users hold them.

Cross-user dedup has a subtle security problem: if the server confirms 'I already have this chunk' before upload, an attacker can probe whether any user stores a specific file (the confirmation-of-file attack), and hash-only 'uploads' let someone claim possession of content they never had. Mitigations include requiring proof-of-possession over random chunk ranges, or scoping dedup to within an account or trust domain. Client-side encryption complicates this further: with per-user keys, identical plaintexts encrypt differently and dedup dies unless you adopt convergent encryption, which reintroduces the probing risk. This tension is a great senior-level talking point.

Garbage collection is the other sharp edge: a chunk is deletable only when no live version in any namespace references it and all retention windows have passed. Synchronous reference counting under concurrent commits is racy, so production systems use asynchronous mark-and-sweep with a deletion quarantine, accepting temporarily higher storage over the risk of deleting a chunk a in-flight commit was about to reference.

Delta sync and the metadata commit protocol

Delta sync falls out of the manifest design: to sync an edited file, the client re-chunks it locally, diffs the new hash list against the previous version's list from its local index, uploads only new hashes, and commits a manifest that mostly points at pre-existing chunks. A one-character edit to a 2 GB file with fixed 4 MB chunks transfers 4 MB, not 2 GB; with CDC, often less.

The commit must be atomic and ordered: (1) all referenced chunks durable in block storage, (2) one transaction inserting the version row, manifest rows, and journal entry, conditional on parent_version matching the current head. Doing metadata before blocks would create versions pointing at missing data; the reverse order merely leaves orphan chunks for GC, which is the safe failure mode. This blocks-then-metadata ordering is worth stating explicitly in an interview.

Downloads mirror uploads: fetch the manifest, diff against local chunks, pull missing ones. Two consequences follow: renames and moves are pure metadata operations regardless of file size, and a new device syncing a large shared folder benefits from LAN sync or peer-assisted transfer since officemates likely already hold most chunks.

Conflict resolution across offline devices

Two devices edit the same file while offline; both come online and commit. The atomic parent-version check makes this safe: device A commits version 6 on parent 5 and wins; device B's commit on parent 5 is rejected because head is now 6. No lock service, no distributed coordination, just optimistic concurrency in the metadata transaction.

The loser must not lose data. The standard resolution, used by Dropbox, is to preserve B's content as a sibling: 'report.docx (conflicted copy from Bob's laptop 2026-08-16)', committed as a new file, and let humans merge. Automatic merging is only safe for formats the server understands (Google Docs solves this with operational transformation, but that is a collaborative-editing system, not a file store). Last-writer-wins is the one clearly wrong answer here because it silently destroys a user's work.

Folder-level races get messier: concurrent rename vs. edit, delete vs. edit inside the deleted folder, or case-sensitivity mismatches across OSes. The design principles that keep this tractable: every mutation goes through the same journal with the same optimistic check, deletes are soft (tombstones plus retention) so a delete/edit race is always recoverable, and the sync engine treats the server journal as the single ordering authority rather than trying to merge device histories peer-to-peer.

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Python + FastAPI for metadata, SQLite for the metadata tables, MinIO (free, S3-compatible) as the block store, watchdog for the client folder watcher, all on one machine.

  1. 01Run MinIO locally with a chunks bucket; create SQLite tables files, file_versions, version_chunks, and chunks.
  2. 02Write the client chunker: fixed 4 MB blocks, SHA-256 each, producing an ordered hash manifest per file.
  3. 03Build POST /files/precommit that returns which manifest hashes the server does not yet have, and PUT /chunks/{hash} that stores a block in MinIO keyed by its hash.
  4. 04Build POST /files/commit that inserts the version row and manifest atomically, conditional on the parent version still being head; reject stale parents.
  5. 05Write the client sync loop: watchdog detects a changed file, re-chunk it, upload only the missing hashes, then commit.
  6. 06Implement download as the mirror image: fetch the manifest, pull only chunks absent from the local chunk cache, reassemble in order.
  7. 07Handle commit rejection by saving the local file as 'name (conflicted copy)' and committing it as a new file.
  8. 08Verify dedup end to end: copy a 1 GB file to a second name and confirm the second upload transfers zero chunks.

Client: fixed-size chunking and dedup upload

python
import hashlib

CHUNK = 4 * 1024 * 1024  # 4 MB fixed blocks

def chunk_manifest(path):
    hashes = []
    with open(path, "rb") as f:
        while True:
            block = f.read(CHUNK)
            if not block:
                break
            hashes.append(hashlib.sha256(block).hexdigest())
    return hashes

def sync_file(api, path, parent_version):
    manifest = chunk_manifest(path)
    # server answers with only the hashes it has never seen
    missing = set(api.post("/files/precommit", hashes=manifest))
    with open(path, "rb") as f:
        for seq, h in enumerate(manifest):
            if h not in missing:
                continue  # dedup: server already has this block
            f.seek(seq * CHUNK)
            api.put_chunk(h, f.read(CHUNK))  # content-addressed PUT
    # blocks are durable first; metadata commit comes second
    return api.post("/files/commit", path=path,
                    hashes=manifest, parent_version=parent_version)

Server: dedup check and optimistic-concurrency commit

sql
-- precommit: which of the client's hashes are new to us?
SELECT h.hash
FROM unnest($1::text[]) AS h(hash)
WHERE NOT EXISTS (SELECT 1 FROM chunks c WHERE c.hash = h.hash);

-- commit: one transaction, conditional on parent still being head
BEGIN;
INSERT INTO file_versions (file_id, version, file_hash)
SELECT $1, $2 + 1, $3
WHERE COALESCE(
  (SELECT MAX(version) FROM file_versions WHERE file_id = $1), 0
) = $2;
-- 0 rows inserted: another device committed first, so the client
-- must create a conflicted copy instead of overwriting head.

INSERT INTO version_chunks (version_id, seq, chunk_hash)
SELECT currval('file_versions_id_seq'), s.ord - 1, s.hash
FROM unnest($4::text[]) WITH ORDINALITY AS s(hash, ord);

INSERT INTO journal (namespace_id, file_id, op)
VALUES ($5, $1, 'commit');
COMMIT;

Bottlenecks & failure modes

  • Metadata DB write throughput and row count (billions of chunk manifest rows) is the true scaling frontier, not block storage; shard by namespace early.
  • Notification fan-out to tens of millions of idle connections; long-poll gateways plus pull-based cursors keep this cheap and loss-tolerant.
  • Hot shared namespaces (a 10K-member company folder) concentrate journal writes and fan-out on one shard; large shared namespaces may need dedicated shards.
  • Client-side chunking and hashing can pin laptop CPUs on huge files; throttle and hash incrementally.
  • GC of unreferenced chunks at PB scale is a massive background scan; poorly scheduled sweeps compete with live traffic for storage I/O.

Key takeaways

  • Split the system into a block plane (dumb, content-addressed, S3-like) and a metadata plane (transactional, the actual brain); almost every feature is a metadata feature.
  • Content-addressed chunks give dedup, delta sync, resumable transfer, and instant copies from one design decision.
  • Order commits blocks-first, metadata-second, so the failure mode is orphaned chunks (GC-able) rather than dangling versions (data loss).
  • Use optimistic concurrency on parent version for conflicts, and preserve the loser as a conflicted copy; never last-writer-wins on user files.
  • Cursor-based journal pull makes sync self-healing: notifications can be lossy because clients always reconcile from their cursor.

Brush up on the underlying topics