Skip to content

Design Dropbox or Google Drive

TL;DR

  • File sync is two systems glued by a manifest: an immutable, content-addressed block store that scales like object storage, and a small strongly consistent metadata store that says which blocks are the current version of a file.
  • The cruxes an interviewer probes: (1) chunking — fixed versus content-defined, and the dedup that follows, (2) delta sync, (3) the manifest store and why it alone needs strong consistency, (4) change notification and conflict resolution.
  • The number that justifies the whole design: delta sync turns 500 TB/day of naive uploads into ~120 TB/day, and content-defined boundaries are what keep a one-byte insert from re-sending the entire file.

Problem statement and clarifying questions

"Design a service that keeps a folder identical across a user's devices, and lets them share it." The interviewer wants the sync client's problem, not a file upload API. Everything interesting happens when two devices disagree.

Question Assumption taken
Scale? 100M registered users, 50M DAU, ~10 file changes per active user per day.
Typical file size and mix? ~1 MB average changed file; 20% of changes are new files, 80% are edits to existing ones.
Block size in production? 1-8 MB. The code below uses ~1.7 KB chunks so a demo fits on a page.
Offline editing? Yes, which is where conflicts come from.
Do we merge concurrent edits? No. Files are opaque bytes; the loser gets a conflicted copy.
Sharing model? Folder-level roles (viewer, editor, owner) with inheritance; per-file grants are a follow-up.
Consistency requirement? The manifest must be strongly consistent per file; blocks may be eventually consistent.
How fast must a change reach another device? Under 5 s p99 while both are online.
Connections per notification node? ~100k idle long-lived connections, so 10M concurrent devices need ~100 nodes.
Versioning and undelete? 30 days of versions and a 30-day trash.

Requirements

Functional

  • Upload, download, move, rename and delete files, keeping every device in a shared folder identical.
  • Transfer only what changed on an edit, and never store the same bytes twice.
  • Keep 30 days of versions and support restoring one.
  • Share a folder with roles; revoking access takes effect immediately.
  • Detect concurrent edits and keep both sides as a conflicted copy.

Non-functional

  • Scale: 5k file changes/s average, 15k/s peak; ~10 Gbps of upload bandwidth average.
  • Consistency: linearizable commit per file (compare-and-set on a version); blocks are immutable and eventually consistent.
  • Durability: 11 nines for stored blocks via erasure coding plus end-to-end checksums; an acknowledged commit is never lost.
  • Availability: 99.99% for metadata reads, 99.9% for uploads, which are retryable.
  • Sync latency: a committed change is visible to another online device within 5 s p99.

Out of scope

Real-time collaborative editing, document preview and rendering, full-text search over file contents, mobile offline-selection policy, enterprise data-residency controls.

Estimation

Using the latency and estimation tables (a day is ~10^5 s, peak is 3x average, a compressed photo is 200 KB-2 MB):

Quantity Arithmetic Result
File changes/day 50M DAU x 10 500M/day = ~5k/s, ~15k/s peak
Naive upload bytes 500M x 1 MB 500 TB/day
With delta sync 100M new x 1 MB + 400M edits x 50 KB ~120 TB/day: a 4x cut
Upload bandwidth 120 TB / 10^5 s ~1.2 GB/s = ~10 Gbps, ~30 Gbps peak
Stored/year 100 TB/day of new content x 365, x0.7 after cross-user dedup ~25 PB/year (x1.4 erasure coded: ~35 PB)
File metadata 100M users x 200 files x 500 B 20B rows, ~10 TB
Manifest rows 20B files x ~1.3 blocks x 32 B ~830 GB, cacheable per user
Change events 500M changes x 2.5 devices 1.25B/day = ~12.5k/s, ~38k/s peak
Notification nodes 10M concurrent devices / 100k per node ~100 nodes plus headroom

Two things to say out loud. Delta sync is worth 4x on bandwidth and dedup is worth ~30% on storage, and those are different mechanisms solving different bills. And the metadata store is small — 10 TB against 25 PB of blocks — which is exactly why you can afford to make it strongly consistent while the block store stays a cheap eventually consistent object store.

API design

Endpoint Request Response Notes
POST /v1/files/{path}:plan {base_version, chunks: [{hash, length}]} 200 {need: [hash]} One round trip of hashes decides the transfer. 16-32 B per block, not megabytes.
PUT /v1/blocks/{hash} raw bytes 201 Content-addressed and idempotent: re-uploading the same hash is a no-op. Large files use a presigned URL instead.
POST /v1/files/{path}:commit {base_version, chunks: [hash], mtime} + Idempotency-Key 200 {version} or 409 {head_version} Compare-and-set. The 409 is the conflict signal, not an error to hide.
GET /v1/files/{path}?version= — 200 {version, chunks: [{hash, length, url}]} Block URLs are short-lived and signed; downloads bypass the API tier.
GET /v1/changes?cursor= — 200 {events: [...], next_cursor} Long poll, up to 30 s. The cursor is a per-user monotonic sequence, so a device never misses an event.
POST /v1/shares {path, principal, role} 201 Grants are on the folder; children inherit unless overridden.

The plan then commit shape is the whole protocol. A client never uploads before asking, and never commits before uploading, so a crash between the two costs nothing but orphaned blocks that a sweeper collects.

Data model

Blocks are immutable and shared by everyone; a version is an ordered list of block hashes.

erDiagram
    USER ||--o{ DEVICE : syncs_from
    USER ||--o{ FILE : owns
    FILE ||--o{ FILE_VERSION : versioned_as
    FILE_VERSION ||--o{ MANIFEST_ENTRY : composed_of
    MANIFEST_ENTRY }o--|| BLOCK : references
    USER ||--o{ SHARE : grants
    USER ||--o{ CHANGE_EVENT : notified_by
    DEVICE {
        uuid device_id PK
        uuid user_id
        bigint last_cursor
        timestamp last_seen_at
    }
    FILE {
        uuid file_id PK
        uuid owner_id
        string path
        int head_version
        bool deleted
    }
    FILE_VERSION {
        uuid file_id PK
        int version PK
        uuid device_id
        bigint size_bytes
        timestamp committed_at
    }
    MANIFEST_ENTRY {
        uuid file_id PK
        int version PK
        int ordinal PK
        string block_hash
    }
    BLOCK {
        string block_hash PK
        int length
        int refcount
        string storage_key
    }
    SHARE {
        uuid folder_id PK
        uuid principal_id PK
        string role
    }
    CHANGE_EVENT {
        uuid user_id PK
        bigint sequence PK
        uuid file_id
        int version
    }

Store choices, with the one sentence to say for each:

  • File, version and manifest: a relational or NewSQL store sharded by owner_id (or by the shared folder's root id), because a commit is a transaction over three rows and must be linearizable. Partition key owner_id, index on (file_id, version desc).
  • Block: object storage keyed by the content hash, with an erasure-coded backend. The database keeps only (hash, length, refcount, storage_key).
  • Change events: an append-only per-user table with a monotonic sequence, which is what makes the long-poll cursor exact rather than time-based.
  • Share: a small table read on every metadata call and therefore cached; folder-level grants with inheritance keep the row count tiny compared with per-file grants.
  • Device: keeps last_cursor server-side too, so a client that loses its local state can resume rather than re-scan everything.

High-level design

v1: metadata and blocks are separate services with separate consistency models.

flowchart LR
    subgraph clients["Clients"]
        c_sync["Sync client (watcher + chunker)"]
        c_web["Web and mobile"]
    end
    subgraph edge["Edge"]
        e_gw["API gateway<br/>auth, quotas"]
        e_cdn["CDN (block downloads)"]
    end
    subgraph services["Services"]
        s_meta["Metadata service<br/>plan, commit, shares"]
        s_block["Block service<br/>presigned URLs"]
        s_notify["Notification service<br/>long poll / WebSocket"]
    end
    subgraph async["Async"]
        a_events[["Change event log"]]
        a_gc["Refcount + orphan sweeper"]
    end
    subgraph data["Data"]
        d_meta[("Metadata DB<br/>files, versions, manifests")]
        d_blocks[("Block store, erasure coded")]
        d_cache[("Manifest + share cache")]
    end
    c_sync --> e_gw --> s_meta --> d_meta
    c_sync --> s_block --> d_blocks
    c_web --> e_cdn --> d_blocks
    s_meta --> d_cache
    s_meta --> a_events --> s_notify
    c_sync --> s_notify
    a_gc --> d_meta
    a_gc --> d_blocks

Write path: plan, upload only the missing blocks, then commit with a version check.

sequenceDiagram
    autonumber
    actor C as Sync client
    participant MS as Metadata service
    participant DB as Metadata DB
    participant BS as Block service
    participant OS as Block store
    participant EV as Change event log
    C->>C: chunk the file, hash each block
    C->>MS: plan(path, base_version, block hashes)
    MS->>DB: which hashes exist
    DB-->>MS: known set
    MS-->>C: need [3 of 40 hashes]
    loop each missing block
        C->>BS: PUT /v1/blocks/{hash}
        BS->>OS: store, erasure coded
        OS-->>BS: ok
        BS-->>C: 201
    end
    C->>MS: commit(path, base_version, hashes)
    MS->>DB: CAS head_version, insert manifest
    alt base_version matches head
        DB-->>MS: version = base + 1
        MS-)EV: append change event
        MS-->>C: 200 {version}
    else stale base
        DB-->>MS: conflict
        MS-->>C: 409 {head_version}
    end

Read path: a device is told what changed, then pulls only the blocks it lacks.

sequenceDiagram
    autonumber
    actor D as Second device
    participant NS as Notification service
    participant MS as Metadata service
    participant DB as Metadata DB
    participant CDN as CDN / block store
    D->>NS: GET /v1/changes?cursor=812 (long poll, 30 s)
    NS-->>D: {events: [report.pdf v2], next_cursor: 813}
    D->>MS: GET /v1/files/report.pdf
    MS->>DB: manifest for head version
    DB-->>MS: ordered block hashes
    MS-->>D: hashes + signed URLs
    D->>D: diff against the local manifest
    loop each block not on disk
        D->>CDN: GET block
        CDN-->>D: bytes (verified against the hash)
    end
    D->>D: reassemble, then update the local cursor

Walk-through: bytes never pass through the metadata service, and manifests never pass through the block service. The metadata service handles a few thousand small transactions a second; the block service handles gigabits of immutable content-addressed blobs that a CDN is happy to cache forever. Long polling with a monotonic cursor means a device that was offline for a week catches up by paging the event log rather than by re-scanning its folder.

Deep dive: content-defined chunking and deduplication

The probing question is "a user adds one line to the top of a 200 MB log file — how much do you upload?" With fixed-size blocks, all of it: every boundary shifts by one byte, so every block hashes differently.

Strategy Insert at the front Boundary cost Dedup reach
Whole-file hash Re-upload the file None Identical files only
Fixed 4 MB blocks Re-upload the file Trivial Identical aligned regions
Content-defined chunking One chunk A rolling hash over the whole file Any shared region, in any file
Fixed blocks + rsync rolling diff One block Server must hold the old copy Same file only

Content-defined chunking cuts where a rolling hash of the last few dozen bytes matches a mask. Because the boundary depends only on nearby content, an insert re-synchronises within one chunk instead of shifting everything after it. A min_size floor stops runs of tiny chunks and a max_size ceiling bounds incompressible data; the realised mean lands near min_size + avg_size, which is a detail worth knowing before you size the mask.

code/hld/chunker_delta_sync.py — rolling hash and chunking
class RollingHash:
    """Rabin-Karp over a sliding window of ``window`` bytes.

    The whole point is that ``push`` is O(1): the byte leaving the window is subtracted with a
    precomputed ``BASE^(window-1)``, so hashing a megabyte costs one multiply-add per byte
    rather than one full window hash per position.
    """

    BASE = 257
    MOD = (1 << 61) - 1  # a Mersenne prime keeps the modulo cheap and collisions rare

    def __init__(self, window: int = WINDOW) -> None:
        if window <= 0:
            raise ValidationError("window must be positive")
        self._window = window
        self._power = pow(self.BASE, window - 1, self.MOD)
        self._buffer: deque[int] = deque()
        self._value = 0

    def push(self, byte: int) -> int:
        if len(self._buffer) == self._window:
            leaving = self._buffer.popleft()
            self._value = (self._value - leaving * self._power) % self.MOD
        self._buffer.append(byte)
        self._value = (self._value * self.BASE + byte) % self.MOD
        return self._value


@dataclass(frozen=True, slots=True)
class Chunk:
    hash: str
    offset: int
    length: int


def chunk_hash(data: bytes) -> str:
    """Content address. 16 hex characters is plenty for a demo; production keeps all 64."""
    return hashlib.sha256(data).hexdigest()[:16]


def content_defined_chunks(
    data: bytes,
    min_size: int = MIN_CHUNK,
    avg_size: int = AVG_CHUNK,
    max_size: int = MAX_CHUNK,
    window: int = WINDOW,
) -> list[Chunk]:
    """Cut where the rolling hash has ``log2(avg_size)`` low zero bits.

    ``min_size`` stops pathological runs of tiny chunks and ``max_size`` bounds the worst case
    on data that never matches (an incompressible blob, or a long run of one byte). Because the
    boundary depends only on the surrounding ``window`` bytes, inserting data early in the file
    re-synchronises within one chunk instead of shifting every boundary after it.

    The realised mean lands near ``min_size + avg_size``, not ``avg_size``: no boundary can be
    taken in the first ``min_size`` bytes, so every chunk carries that floor before the
    geometric tail starts. Size the mask for the mean you actually want.
    """
    if avg_size & (avg_size - 1):
        raise ValidationError("avg_size must be a power of two")
    if not 0 < min_size <= avg_size <= max_size:
        raise ValidationError("need 0 < min_size <= avg_size <= max_size")
    mask = avg_size - 1
    hasher = RollingHash(window)
    chunks: list[Chunk] = []
    start = 0
    for index, byte in enumerate(data):
        value = hasher.push(byte)
        length = index - start + 1
        if length < min_size:
            continue
        if value & mask == 0 or length >= max_size:
            chunks.append(Chunk(chunk_hash(data[start : index + 1]), start, length))
            start = index + 1
    if start < len(data):
        chunks.append(Chunk(chunk_hash(data[start:]), start, len(data) - start))
    return chunks


def fixed_chunks(data: bytes, size: int = AVG_CHUNK) -> list[Chunk]:
    """The naive alternative, kept so the demo can show what it costs on an insert."""
    if size <= 0:
        raise ValidationError("size must be positive")
    return [
        Chunk(chunk_hash(data[offset : offset + size]), offset, len(data[offset : offset + size]))
        for offset in range(0, len(data), size)
    ]

The demo makes the difference concrete on 64 KB of random data, and shows why the insert case is the one that matters:

report.pdf v1: 65536 bytes -> 38 chunks (avg 1724 B)
insert 1 byte at offset 0:
  fixed 1 KB blocks:  65/ 65 chunks resent,  65537 B (0% saved)
  content-defined:     1/ 38 chunks resent,   1193 B (98% saved)
edit 100 bytes in the middle:
  fixed 1 KB blocks:   1/ 64 chunks resent,   1024 B (98% saved)
  content-defined:     1/ 38 chunks resent,   1488 B (98% saved)
append 4 KB at the end:
  fixed 1 KB blocks:   4/ 68 chunks resent,   4096 B (94% saved)
  content-defined:     3/ 40 chunks resent,   6971 B (90% saved)
chunk store: 38 unique blocks, 3 duplicate puts deduplicated, 65536 B stored
conflict: report.pdf moved to v2 while bob edited v1
  resolved by keeping both: 'report.pdf (bob's conflicted copy)' v1 (65536 B)

Deduplication then falls out of addressing: the store's key is the block's hash, so the same attachment in a thousand mailboxes is stored once. Two caveats to volunteer. Cross-user dedup leaks information — an attacker who can time an upload learns whether a block already existed, which is a known side channel; scoping dedup per user or per team removes it at the cost of storage. And deletion needs reference counts, because you cannot free a block another manifest still points at.

Deep dive: delta sync and the upload path

The probing question is "walk me through exactly what crosses the wire when a 1 GB video's metadata tag changes." The answer is a list of hashes and one block.

The client chunks locally, hashes each chunk, and sends only the hashes to plan. The server answers with the subset it does not already hold — which, thanks to cross-user dedup, is often fewer blocks than the file changed, because someone else already uploaded those bytes. The client uploads exactly those and commits the full ordered hash list. For a 1 GB file at 4 MB blocks that is 256 hashes, about 8 KB of request, to avoid transferring a gigabyte.

Two choices worth defending. Where the bytes go: small blocks through a block service (which can batch, compress and verify), large ones straight to object storage through a presigned URL. Proxying bytes through a service is a bandwidth bill with no benefit once the payload is large. Where the chunking happens: on the client, always. Server-side chunking means uploading the whole file first, which defeats the point, and client-side chunking gives resumability for free — an interrupted upload resumes at the first missing hash.

Two failure cases the interviewer will reach for. A crash between upload and commit leaves orphaned blocks; a sweeper collects blocks whose refcount has been zero for longer than a safety window. And a client that commits a hash it never uploaded: the server checks every hash in the commit exists and is readable before advancing the version, and verifies bytes against their hash on write, because a content-addressed store that does not verify addresses is one an attacker can poison.

Interview tip

Lead with "the client chunks and hashes, then asks the server which hashes it needs." That single sentence contains delta sync, deduplication, resumability and the reason the API has a plan call — and it lands in fifteen seconds.

Deep dive: the manifest store and why it is the strongly consistent part

The probing question is "which part of this needs a transaction?" Exactly one: advancing a file to a new version.

Blocks are immutable and content-addressed, so they need no coordination at all — two clients uploading the same bytes race harmlessly, and a replica that has not caught up is a retry, not a correctness bug. The manifest is the opposite. "Which ordered list of hashes is the current version of report.pdf" is a single mutable value that two devices actively compete for, and a lost update there means lost work.

Use optimistic concurrency on an integer version: commit supplies base_version and the store advances only if it still equals the head. No lock is held while a user edits, which matters because that can be hours; the conflict surfaces at commit, which is the only moment anyone can act on it.

code/hld/chunker_delta_sync.py — versioned manifests with compare-and-set
@dataclass(frozen=True, slots=True)
class FileVersion:
    file_id: str
    version: int
    chunks: tuple[str, ...]
    actor: str
    size: int


class MetadataStore:
    """Versioned manifests with compare-and-set commits. ``_lock`` guards ``_history``.

    This is the one component that must be strongly consistent. Chunks are immutable blobs and
    can live in an eventually consistent object store, but "which chunks are the current
    version of this file" is a single value that two clients race for, and the loser has to
    find out. Optimistic concurrency on an integer version is enough: no locks are held across
    a user's editing session, and the conflict surfaces at commit time.
    """

    def __init__(self) -> None:
        self._history: dict[str, list[FileVersion]] = {}
        self._lock = threading.Lock()

    def head(self, file_id: str) -> FileVersion | None:
        with self._lock:
            history = self._history.get(file_id)
            return history[-1] if history else None

    def commit(self, file_id: str, base_version: int, chunks: list[Chunk], actor: str) -> FileVersion:
        """Advance the file to ``base_version + 1``, or raise ``ConflictError``."""
        with self._lock:
            history = self._history.setdefault(file_id, [])
            current = history[-1].version if history else 0
            if base_version != current:
                raise ConflictError(
                    f"{file_id} moved to v{current} while {actor} edited v{base_version}"
                )
            version = FileVersion(
                file_id,
                current + 1,
                tuple(c.hash for c in chunks),
                actor,
                sum(c.length for c in chunks),
            )
            history.append(version)
            return version

    def conflicted_copy(self, file_id: str, chunks: list[Chunk], actor: str) -> FileVersion:
        """The losing client keeps its work as a new file rather than losing or merging it."""
        return self.commit(f"{file_id} ({actor}'s conflicted copy)", 0, chunks, actor)

    def known_hashes(self) -> frozenset[str]:
        with self._lock:
            return frozenset(h for history in self._history.values() for v in history for h in v.chunks)

Three practical notes. Shard by owner (or by the root of a shared folder) so a commit is a single-shard transaction; a move across shared folders becomes a two-shard operation and is the one place a saga or a two-phase commit is justified. Cache manifests aggressively but never the head version — read the version from the primary on commit, or you will happily compare-and-set against a stale value. And keep versions cheap: a version is a row plus manifest entries pointing at blocks that mostly already exist, so 30 days of history costs metadata rows, not petabytes.

Deep dive: change notification and conflict resolution

The probing question is "two people edit the same file offline — what do you do?" Never merge opaque bytes and never silently pick a winner. Keep both.

Notification comes first, because conflicts are what happen when it fails. Options are polling (simple, wasteful, slow), long polling (one held request per device, up to 30 s, cheap enough at 10M devices) and WebSockets (lowest latency, most connection state). Long polling with a monotonic per-user cursor is the pragmatic default: a device sends its cursor, the server holds the request until there is an event or the timeout expires, and no event can be missed because the cursor is a sequence number rather than a timestamp.

The conflict, end to end.

sequenceDiagram
    autonumber
    actor A as Alice device
    participant MS as Metadata service
    actor B as Bob device
    participant NS as Notifier
    A->>MS: plan(report.pdf, base=v1)
    B->>MS: plan(report.pdf, base=v1)
    MS-->>A: need [hashes]
    MS-->>B: need [hashes]
    A->>MS: commit(base=v1)
    MS-->>A: 200 v2
    MS-)NS: changed(report.pdf, v2)
    B->>MS: commit(base=v1)
    MS-->>B: 409 head is v2
    Note over B: keep both, never merge bytes
    B->>MS: commit(report.pdf conflicted copy, base=v0)
    MS-->>B: 200 v1
    NS-->>A: pull v2 and the conflicted copy

Bob's client renames its version to report (Bob's conflicted copy).pdf and commits it as a new file, so both edits survive and a human decides. Last-writer-wins would be simpler and would silently destroy Bob's afternoon, which is why no sync product does it for file contents. The alternatives are worth naming: three-way merge works for text and is what version control does, but requires a common ancestor and structured content; CRDTs work for structured documents and are the collaborative-editor answer, not this one.

Common mistake

Answering "last write wins" for conflicting file edits. It is the one answer that guarantees data loss, and it signals that you have not thought about the offline case at all. Say "conflicted copy", then explain when a real merge is possible instead.

Scaling, bottlenecks and failure modes

v2: sharded metadata, regional block storage, a dedicated notification tier and a sweeper.

flowchart LR
    subgraph clients["Clients"]
        c_dev["Sync clients (10M concurrent)"]
    end
    subgraph edge["Edge"]
        e_gw["Regional gateways"]
        e_cdn["CDN for block reads"]
    end
    subgraph services["Services"]
        s_meta["Metadata service (stateless)"]
        s_notify["Notification tier<br/>sticky by user_id"]
        s_share["Permission service + cache"]
    end
    subgraph async["Async"]
        a_events[["Change log, partitioned by user_id"]]
        a_sweep["Refcount GC + orphan sweeper"]
        a_scrub["Checksum scrubber"]
    end
    subgraph data["Data"]
        d_meta[("Metadata shards by owner_id<br/>3 replicas, linearizable")]
        d_blocks[("Block store, erasure coded 10+4")]
        d_cache[("Manifest + permission cache")]
    end
    c_dev --> e_gw --> s_meta --> d_meta
    c_dev --> s_notify --> a_events
    c_dev --> e_cdn --> d_blocks
    s_meta --> s_share --> d_cache
    s_meta --> a_events
    a_sweep --> d_meta
    a_sweep --> d_blocks
    a_scrub --> d_blocks

What breaks first, and what you do about it:

  • A shared folder with 10,000 members. One commit fans out 10,000 change events and every client asks for the manifest at once. Coalesce events per user, cache the manifest, and jitter the clients' back-off so the herd spreads.
  • A hot metadata shard, usually a huge team folder. Shard shared content by folder root rather than by user, and cap the members per folder.
  • The notification tier. 10M long-lived connections is the stateful part of an otherwise stateless system: route by user_id so a user's devices land on one node, and make reconnection cheap, because mobile networks drop connections constantly.
  • A client stuck in a sync loop (a file another program keeps rewriting) burns bandwidth and version numbers. Debounce local changes, coalesce rapid edits, rate-limit commits per file.
  • Block-store failure. Erasure coding survives node loss; a scrubber re-reads and verifies checksums so bit rot is repaired before a read finds it.
  • Permissions. Revocation must be immediate, so checks sit on the metadata path with a short TTL and explicit invalidation, and block URLs are signed and short-lived — a leaked URL that works for a week is a revocation that never happened.
  • Cost. Deduplication and 30-day versions fight each other; the sweeper keeps that bounded, and cold single-reference blocks are the tier-to-archive candidates.

Trade-offs summary

Decision Chosen Alternatives Why
Chunking Content-defined, rolling hash Fixed blocks, whole file An insert costs one chunk instead of the whole file
Dedup scope Global content-addressed blocks Per user, none ~30% storage saving; note the timing side channel
Transfer protocol Plan by hash, then upload the missing blocks Upload then diff server-side The client never sends bytes the server already has
Large uploads Presigned URL straight to object storage Proxy through a block service Keeps ~10 Gbps out of your services
Manifest commit Compare-and-set on an integer version Locks, last-writer-wins No lock held across an editing session; no silent data loss
Consistency split Strong for manifests, eventual for blocks Strong everywhere 10 TB of metadata can afford it; 25 PB of blocks cannot
Change notification Long poll with a monotonic cursor Polling, WebSockets Cheap at 10M devices and cannot miss an event
Conflicts Conflicted copy Last-writer-wins, auto-merge Opaque bytes cannot be merged; losing work is unacceptable

Interviewer follow-ups

Why not just use rsync's rolling checksum?

Rsync computes a delta against a copy the server already has, which means server CPU per transfer and no benefit across users. Content-defined chunking produces stable, globally addressable blocks: the same chunk deduplicates against every other file in the system, and the server does no diffing at all.

How do you handle a 50 GB file?

The same way, with bigger blocks and a presigned upload URL per block, uploaded in parallel and resumable at block granularity. The manifest gets large (12k hashes at 4 MB blocks), so page the plan call and store the manifest in chunks of its own.

What does the client do on first sync of a huge folder?

Page the metadata by cursor and download blocks in the background with priority given to recently modified files, so the user sees something useful within seconds. Also mention selective sync: placeholder files that materialise on open, which is how the product avoids downloading a terabyte onto a laptop.

How do you make sure a user cannot read a block they do not own?

Blocks are addressed by hash, so guessing an address is guessing a SHA-256 — but that is not an access control model. Authorisation happens at the metadata layer: you only ever learn a block hash by reading a manifest you are allowed to read, and block URLs are short-lived signatures scoped to that request.

How does deletion actually free space?

A delete is a tombstone on the file plus a decrement of every referenced block's refcount, applied asynchronously. Blocks at zero references for longer than the trash window are collected. Never free synchronously: a delete of a 50 GB file would otherwise be a 12,000-block transaction.

Where would you accept eventual consistency?

Block reads, share caches, the change event fan-out, and version history listings. Not the head version of a file, not permission revocation, and not the refcount used to decide a deletion.

How do you support 'restore to yesterday'?

Versions are just manifests, so restoring is a new commit whose block list is an old version's. That is why version history costs metadata rather than storage, and why the operation is instant regardless of file size.

45-minute pacing

Minutes What to say and draw
0-5 Clarify: 50M DAU, offline edits, no content merge, folder sharing, 5 s sync latency.
5-9 Estimation: 500M changes/day, 500 TB naive versus ~120 TB with delta sync, 10 TB metadata against 25 PB blocks.
9-14 API (plan, put block, commit, changes) and the data model; say "a version is an ordered list of block hashes".
14-24 v1 diagram; narrate plan-upload-commit and the notify-then-pull read path.
24-40 Deep dives: content-defined chunking with the insert example, delta sync and the upload path, the compare-and-set manifest commit, conflicted copies.
40-45 Bottlenecks (huge shared folders, hot shards, the notification tier, GC) and the trade-offs table.