← System design

Storage & Data

Design Dropbox Sync

Saving a file should be the most boring thing a computer does. A sync product makes it slightly magical: save on a laptop, and the same bytes appear on a phone and a desktop moments later, even if one of them was on a plane. Underneath sits a hashed block store, a metadata journal that every device reads like a log, and a client engine that has to reconcile three versions of the truth without ever deleting someone's work by accident.
Core: content-addressed blocks + journal cursors + three-tree reconcile400M users, 70M active devices a dayOffline-first, eventually consistentChanges on other devices in under 10 s

01

Scope and promises

02

Scope and promises

Functional
  • Any change in the synced folder on one device, whether create, edit, rename, move or delete, appears on the user's other devices.
  • Devices work fully offline and catch up when they reconnect, in either direction.
  • Only the changed parts of a file travel over the network, not the whole file.
  • Users share folders with others, who see them as part of their own tree, with view or edit permissions.
  • Every file keeps its previous versions for 30 days, and deleted files can be restored in that window.
  • Clients exist for desktop, mobile and the web; mobile devices fetch file contents on demand rather than mirroring everything.
Non-Functional
  • A small edit reaches other online devices in under 10 seconds at p95.
  • No data loss: once the server acknowledges a commit, the file survives the loss of a data centre.
  • Conflicting edits never silently overwrite each other.
  • Metadata operations are consistent within one folder namespace: every device sees changes in the same order.
  • Storage cost per user stays low through deduplication and erasure coding.
Draw the line with collaborative editing

This is file sync, not real-time co-editing. Two people typing into one document at once is the territory of operational transforms and CRDTs. Here, the unit of change is a whole file version, and when two versions collide we keep both. Saying so early saves ten minutes of the wrong discussion.

02

Estimating storage and traffic

03

Estimating storage and traffic

Assume 400 million registered users, of whom 70 million devices are active on a given day. The average account holds 12 GB of logical data across about 6,000 files. Each active device produces about 150 file changes a day, most of them small.

QuantityWorkingResult
Logical data400M x 12 GBabout 4.8 EB
After dedupabout 35% of blocks are already stored, across versions and accountsabout 3.1 EB unique
Physical with erasure coding3.1 EB x 1.5 overheadabout 4.7 EB on disk
File records400M x 6,000, plus version rowsabout 2.4T files, metadata about 1 PB
File changes per day70M devices x 150about 10.5B, roughly 120,000 / s
Peak change rate2.5x average during working hoursabout 300,000 / s
New block uploadsabout 1 in 8 changes needs a new block, averaging 1.5 MB after compressionabout 15,000 / s, 22 GB / s
Downloadseach upload reaches about 1.8 other devicesabout 40 GB / s
Open notify connectionsdesktop clients online at peakabout 45M long polls
Two systems hiding in one

Bytes are huge and immutable; metadata is small and changes constantly. Exabytes of blocks want cheap, erasure-coded storage; a petabyte of metadata wants a transactional database with strong ordering. Mixing them would make both worse.

Dedup pays for itself

Cutting 4.8 EB of logical data to 3.1 EB of unique blocks saves roughly 1.7 EB before erasure coding. At that size, a few percent of extra dedup is worth more than most feature work.

03

API

04

API

Clients use three surfaces: a metadata API for the tree and its history, a block API for raw bytes, and a notify endpoint that tells a device when to look again.

POST/2/blocks/missingWhich of these do you lack?
{ "hashes": ["b3:9f21...", "b3:04ac...", "b3:e7d0..."] } { "missing": ["b3:e7d0..."] } // only blocks the caller is allowed to reference
POST/2/blocks/{hash}Upload one block
// body: up to 4 MB, compressed; server recomputes the hash and rejects a mismatch // 201 Created, or 200 if it already existed; safe to repeat
POST/2/files/commitPublish a new version
{ "ns": 88104, "path": "/Thesis/chapter-3.docx", "base_rev": "r_5e0a91", // the version this edit started from "blocks": ["b3:9f21...", "b3:04ac...", "b3:e7d0..."], "size": 10485231, "client_mtime": "2026-10-01T11:02:09Z" } // 200: committed as a new journal entry { "rev": "r_5e0b02", "journal_seq": 771203 } // 409: base_rev is not the latest; client keeps its copy as a conflicted file
GET/2/journal?ns=88104&cursor=771180Changes since a cursor
{ "entries": [ { "seq": 771190, "op": "move", "from": "/Drafts/intro.md", "to": "/Thesis/intro.md" }, { "seq": 771203, "op": "write", "path": "/Thesis/chapter-3.docx", "rev": "r_5e0b02" } ], "cursor": 771203, "has_more": false }
GET/2/notify?cursors=88104:771203,90551:4410&timeout=90Long poll for "something changed"
{ "changed": [88104], "backoff_s": 0 } // no content, just which namespaces to re-read

04

System layout

05

System layout

Client engine, metadata, blocks and notifyDiagram
flowchart LR
	FS["Local disk and file watcher"] --> SE["Sync engine, three trees"]
	SE --> MD["Metadata API"]
	SE --> BA["Block API"]
	SE --> NT["Notify long poll"]
	MD --> JDB[("Journal and file tree, sharded by namespace")]
	MD --> SH["Sharing and permissions"]
	BA --> BIX[("Block index: hash to location, refcount")]
	BA --> BS[("Erasure-coded block store")]
	JDB -. "commit events" .-> BUS[["Change bus"]]
	BUS --> NT
	GC["Garbage collector"] --> BIX
	GC --> BS

The server side splits cleanly in two. The block tier stores immutable chunks of file content, each named by the hash of its bytes. Because the name is derived from the content, two identical chunks anywhere in the system share one stored copy, and an upload of a chunk that already exists is a no-op. A block index maps each hash to its physical location and a reference count. The physical store is a fleet of dense disk servers that keep blocks in large erasure-coded volumes, the approach Dropbox described when it moved off public cloud storage to its own system, Magic Pocket.

The metadata tier holds the file tree. Every user's home folder and every shared folder is a namespace, and each namespace has an append-only journal: a strictly ordered list of changes, each with a sequence number. The current tree is a materialised view of that journal. A file version is just a row listing the block hashes that make up the file, in order. Namespaces are the shard key, which keeps every change to one folder on one database shard where ordering is cheap.

The notify tier holds tens of millions of idle long-poll connections. It does not carry content. When a commit lands, the metadata tier publishes "namespace 88104 is now at sequence 771203" to a change bus, and notify servers wake any device that is waiting on that namespace with an older cursor. The device then reads the journal itself. Keeping notify dumb means a lost notification only delays sync until the next poll, never corrupts it.

On the device, the sync engine watches the file system, chunks and hashes changed files, uploads missing blocks, commits new versions, and applies remote changes from the journal. It stores its own local database of what it believes is synced, which is the key to doing that safely, as the deep dive explains.

Block size

4 MB is a practical middle: small enough that an edit to a large file only re-sends a few blocks, large enough that the block index stays at hundreds of billions of entries rather than trillions.

Permissions on blocks

A client may only learn that a block exists, or download it, if a file it can read references that block. Otherwise dedup becomes a way to test whether someone else has a given file.

Sharing as mounting

A shared folder is its own namespace mounted into each member's tree. One commit to it appears in every member's view without copying the journal per member.

Mobile and web

They read the same journal but download blocks lazily, when a file is opened, and can request server-side previews rather than raw blocks.

05

Deep dive: from a save on disk to every other device

06

Deep dive: from a save on disk to every other device

Two hard parts

Moving bytes efficiently is the easier half. The harder half is deciding what each change means: is a missing file a delete, a move, or something the user has not downloaded yet? Getting that wrong deletes people's work.

Uploading only what changed

Say a user edits a 10 MB document and saves. The file watcher (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows) reports the path. The engine waits briefly for writes to settle, then splits the file into three blocks and hashes each. It asks the block API which of those three hashes it lacks. Typically only the block containing the edit is new. The engine uploads that one block, compressed, and then commits a new version listing all three hashes with the revision it started from.

The commit is the moment the change becomes real. It is a single transaction on the namespace's shard: check that base_rev is still the latest version of the path, append a journal entry, update the file row, and increment the reference count of any newly referenced block. If the base revision is stale, someone else changed the file first, and the commit fails with a conflict rather than overwriting their work.

Upload side of one saveDiagram
flowchart TD
	W["Watcher reports chapter-3.docx changed"] --> D["Debounce until writes stop"]
	D --> H["Split into 4 MB blocks and hash each"]
	H --> M["Ask server which hashes are missing"]
	M --> U["Upload only the missing blocks"]
	U --> C{"Commit with base_rev"}
	C -->|base_rev is latest| J["Journal entry appended, devices notified"]
	C -->|stale| K["Keep local copy as a conflicted file, then pull theirs"]

Three trees on every device

A naive engine compares the local folder with the server and copies differences in whichever direction seems newer. It fails the first time a device comes back from a week offline: a file absent locally could mean "the user deleted it here" or "it was created on the server while we were away". Timestamps cannot settle it, because clocks lie and copying a file can preserve old modification times.

The fix, which Dropbox described when it rewrote its sync engine as Nucleus, is to keep three trees. The remote tree is what the server journal says. The local tree is what is on disk now. The synced tree is the last state on which both sides agreed. Each change is then classified by comparing against the synced tree rather than against the other side:

SyncedLocalRemoteMeaning and action
PresentMissingUnchangedDeleted locally: commit a delete
AbsentMissingPresentCreated remotely: download it
Version AVersion BVersion AEdited locally: upload and commit
Version AVersion BVersion CEdited on both sides: keep both as a conflict
PresentEditedDeletedEdit wins: recreate the file on the server

The last row reflects a policy worth stating: when one side deletes and the other edits, the edit wins, because losing a deletion costs a user a few seconds while losing an edit can cost them hours. The same reasoning drives conflicts in general. Instead of merging, the engine saves the losing version next to the original with a name like chapter-3 (Asha's conflicted copy 2026-10-01).docx and lets the person decide.

The engine must also recognise its own writes. When it downloads a remote change and writes it to disk, the file watcher fires. Without care, the engine would see a "local edit", upload it, and loop forever. Recording the expected hash of every file it writes, and ignoring watcher events that match, breaks the loop.

Moves and renames

A rename of a folder with 20,000 files must not become 20,000 deletes followed by 20,000 uploads. The engine tracks files by a stable local ID, such as the inode on Unix-like systems, so that a path disappearing in one place and appearing elsewhere with the same ID is classified as a move. On the server, a move is a single journal entry that rewrites the path of one directory row, and every other device applies it as a local rename. No block moves anywhere.

The download side and the block store

Other devices learn of the commit through notify, read the journal from their cursor, and see a new version of chapter-3.docx listing three hashes. Each device checks its own local block cache, finds it already has two of them from the previous version, and downloads only the third. It assembles the file in a temporary location, verifies the hashes, and atomically renames it into place, so an application never sees a half-written file. Only then does it advance its synced tree and its cursor.

On the server, new blocks first land in a replicated write buffer so the commit can be acknowledged quickly. Background workers then pack blocks into large volumes of a gigabyte or more and erasure-code each volume across many disks and racks, for example 6 data fragments plus 3 parity. That keeps any block readable after losing several disks or a whole rack, at about 1.5 times the raw size instead of 3 times for plain replication. A copy is also kept in a second region, because the durability promise covers losing a whole data centre.

06

Design choices

07

Design choices

Chosen
Fixed 4 MB blocks

Simple to compute, fast to hash, easy to stream and to address. An insertion near the start of a file shifts every later block, so some edits re-upload more than they strictly need to.

Simple and fast
Alternative
Content-defined chunking

A rolling hash picks boundaries from the content itself, so an insertion only changes nearby chunks. Saves bandwidth on large files with shifting edits, at the cost of CPU on every client and variable chunk sizes to manage.

For large, often-edited files
Chosen
Separate metadata and block stores

Each is built for its shape: a sharded transactional database for small, ordered, mutable records, and an erasure-coded blob store for huge, immutable bytes. You need reference counting and garbage collection to keep them consistent.

Each part fits its data
Rejected
Whole files in one object store

Straightforward, and fine for a prototype. It loses delta sync and dedup, and a one-byte edit to a 2 GB video becomes a 2 GB upload.

Too wasteful at scale
Chosen
Keep both versions on conflict

Nothing is ever lost and the rule is easy to explain. Users occasionally have to tidy up a conflicted copy.

Safe default
Rejected
Last writer wins

No extra files and no user involvement. With clocks that disagree and devices offline for days, the "last" writer is often the wrong one, and someone's work silently disappears.

Loses data
Chosen
Notify plus pull from the journal

Notifications carry no data, so losing one only delays sync. The journal cursor gives exact, ordered catch-up after any gap. Costs one extra round trip per change.

Robust to drops
Rejected
Push full changes to devices

Saves the round trip but makes the notify tier responsible for ordering and delivery. A device that misses one push now has a hole in its view.

Fragile

07

What goes wrong

08

What goes wrong

📶
Connection drops halfway through a block

A laptop on train Wi-Fi gets 2.7 MB into a 4 MB block and loses the network. The server holds a partial upload that matches no hash.

The server hashes what it received and only accepts a block whose hash matches its name. Partial data is discarded, and the client simply retries the same block, since uploads are idempotent by hash.
🔁
The engine syncs its own writes

Applying a remote change writes a file, the watcher reports it, and the engine uploads it as a new local edit. Two devices can bounce the same file back and forth until someone notices the version history.

Record the hash of every file the engine itself writes and ignore watcher events that match it. Compare against the synced tree, never just the watcher.
🗑️
Garbage collection deletes a block still in use

A block's reference count drops to zero when its last file version expires. At the same moment, a client that checked "missing" a second ago is committing a new version that references it.

Never delete a block the moment its count hits zero. Mark it, wait a grace period longer than any commit window, recheck, and only then reclaim the space. Keep a mark-and-sweep audit to catch refcount drift.
🧳
A device returns after months offline

Its cursor points at journal entries that have since been compacted away, and it holds edits made against versions that no longer exist.

When a cursor is too old, the server returns a "reset" and the client re-lists the namespace, then reconciles with its synced tree. Its offline edits become new versions or conflicted copies, never silent overwrites.
🔥
One shared folder gets very hot

A company shares a single folder with 30,000 employees and a build process writes into it constantly. Its namespace shard takes thousands of commits a second and 30,000 devices re-read its journal after each.

Coalesce notifications per namespace over a short window, rate limit commits per namespace, and move very large shared namespaces to dedicated shards.

08

Interview advice

09

Interview advice

1

Lead with chunks and hashes. Naming each block by its content hash buys three things at once: delta uploads, cross-account dedup and retries that cannot corrupt anything. Put it on the board before any boxes.

2

Split metadata from bytes. Say why each needs a different store, and describe the reference count and garbage collector that connect them.

3

Explain the synced tree. Comparing local with remote is ambiguous; comparing each with the last agreed state is not. Few candidates get here, and it is the heart of a correct engine.

4

Conflicts keep both copies. Contrast that with collaborative editors so the interviewer knows you understand the scope.

5

Use a journal and cursors. They give ordered catch-up for devices that were offline, and let notifications be lossy hints.

6

Mention the security side of dedup. Only reveal or serve a block to someone whose files reference it, otherwise dedup leaks information.

09

How it grows

11

How it grows

Stage 1: the first product
A desktop client, one database, cloud object storage

Fixed blocks with hashes, a single metadata database, blocks in a public object store and clients polling every minute. It works for a surprising number of users.

Stage 2: tens of millions of users
Sharded metadata and long-poll notify

Shard the metadata by namespace, add a per-namespace journal with cursors, and replace polling with long-poll notifications so idle clients cost almost nothing.

Stage 3: exabytes of data
Own block storage

Storage becomes the largest cost. Move blocks to self-run, erasure-coded storage with its own block index and garbage collector, which Dropbox did with Magic Pocket.

Stage 4: correctness at scale
Rewrite the sync engine around three trees

Rare sync bugs become frequent at hundreds of millions of devices. Rebuild the client around remote, local and synced trees with exhaustive randomised testing of the reconcile logic.

Stage 5: beyond the desktop
On-demand files and smarter transfers

Show files as placeholders until opened, sync on mobile and web lazily, and consider content-defined chunking or LAN sync between nearby devices to cut bandwidth further.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.