Skip to content

System DesignWorked problems

Design Dropbox

Design a file sync service like Dropbox. Start with one server storing whole files, grow it with blocks named by their hash, blocks before metadata, a journal per folder split by folder and long polls, then stress it with where to cut files, safe deletes, conflicts, a huge folder and the storage bill, with code.

Senior29 min core · 46 min full depth

“Design Dropbox. A folder on my laptop, my phone and the web that always has the same files.”

Storage is the easy half of file sync. The hard half is keeping several copies of a folder in agreement. Devices edit offline, uploads drop halfway, and two people save the same file a second apart. And all of it must move as few bytes as possible.

So this page starts with one server that stores whole files, and lets the numbers grow it. Then it tests the result against chunking, deleting safely, conflicts, a huge folder and the storage bill. For the file tree itself, see the low-level twin, Design an in-memory file system.

If you only have a minute, this is the design. The rest of the page explains each part.

  1. Files are split into blocks, each named by its hash. A one-byte edit to a 200 MB file uploads one small block, not the file. Identical blocks are stored once.
  2. Blocks first, then the new version. A version is a list of block hashes. It’s recorded only after every block it names is safely stored, so a file the server lists can always be downloaded.
  3. Each folder has a journal. An ordered list of changes, numbered 1, 2, 3. Each device remembers its place, called a cursor, and syncs by asking for everything after it.
  4. A save only succeeds if nobody else changed the file first. If two devices edit the same version, one wins the path and the other becomes a “conflicted copy”. No edit is ever silently lost.
  5. Devices wait on a long poll. The server answers “something changed” the moment it does, and the device pulls the changes.

A few questions decide how much we store, and what happens when edits collide.

Interviewerminute 0

Design Dropbox. A folder that stays in sync across a user’s devices.

What they are testing

Whether you find the few answers that change the design: scale, largest file, sharing, version history and what happens on conflicting edits. Three minutes is right. Opening with “S3 plus a database” before asking whether two people can edit one folder reads as a memorized diagram.

These five questions matter most:

  • How many users? 500 million registered, 100 million a day.
  • Largest file? 50 GB. That needs uploads that resume block by block.
  • Shared folders? Yes, with up to thousands of members.
  • Version history? 30 days of old versions and deleted files. So a block can’t be deleted the moment no current file uses it.
  • Two devices edit the same file? Keep both. Live co-editing is a different system, the collaborative editor.

From these answers we make five promises:

  • Never lose a save. Once the client is told a save succeeded, it’s never lost.
  • Local files always open. Sync works 99.95% of the time, but files already on a device open whatever the server is doing.
  • Fast. A small change reaches other online devices within 5 seconds.
  • Light. An edit uploads roughly the bytes that changed.
  • One order per folder. Every device sees changes to a folder in the same order, and no edit silently overwrites another.

We leave out live co-editing, full-text search, and previews.

Go deeperMore questions, and what each changes1 min
Question We assume If the answer were different
Ten times fewer users? No One metadata database and an off-the-shelf object store, no sharding
Largest file 1 GB? No, 50 GB A client could hold a whole file in memory
Personal folders only? No, shared too Each user is one folder tree; no fan-out to other users
Phones keep full copies? No, they fetch on demand Phones would need the full sync engine too

Now we work out the numbers. Each one should change a decision.

Assumptions
Daily active users
100 million
File changes per user a day
10
Metadata reads per commit
5
Peak over average
3×
New bytes uploaded per change
400 KB
Uploaded bytes already stored
25%
Devices online at peak
50 million
Registered users
500 million
Files per registered user
1,000
Metadata bytes per file
1 KB
Commits12,000/s35,000/s at peak

100 million daily users × 10 changes a day = 1 billion a day.

So we one database could still take the writes; size, below, is what forces shards.

Metadata reads58,000/s170,000/s at peak

Each change wakes other devices and folder members, who pull it: about 5 reads per change.

So we reads are past one database.

Connections50Mdevices online at peak

Polling every 60 seconds instead would be 830,000 requests a second, nearly all answering “nothing new”.

So we hold a waiting request per device, on servers sized by connections.

Uploads14 GB/sat peak

With block-level sync, a change uploads about 400 KB of new data.

So we bytes get their own servers, away from metadata.

Storage growth110 PBnew data a year

After removing the 25% of data that’s already stored somewhere.

So we storage is the biggest bill.

Metadata size500 TBbefore history

500 million users × 1,000 files × about 1 KB per file.

So we shard metadata from day one.

Try the calculator. With only 10 million devices online, waiting requests stop paying for themselves. At 150 KB of new bytes per change, storage growth falls to about 41 PB a year.

Change an assumption
Commits, average
12,000/s
Commits, peak
35,000/s
Inside what one database primary commits; size, not writes, forces sharding.
Metadata reads, peak
170,000/s
Past the 100,000 reads/s of one database: shard, and serve deltas from replicas.
Poll requests per real request, polling every 60 s
4.8
Polls outnumber real requests and nearly all answer "nothing new": hold long polls instead.
Notify servers (60% of 500,000 polls each)
170
Sized by open connections, not by requests.
Upload bandwidth, peak
14 GB/s
A fleet of block servers. Bytes never pass through the metadata tier.
Download bandwidth, peak (2 devices per change)
28 GB/s
Personal files are fetched by a couple of devices, so a CDN helps only shared links.
New unique data a year
110 PB
Storage is the largest bill: erasure-code, tier cold blocks, and price owning the hardware.
Metadata, current state
500 TB
Far past one node: shard by namespace from day one.

We start with the simplest design that is correct. Then we let the numbers break it, one at a time. Each step fixes only what broke, adds only the boxes that fix needs, and says what the fix costs. Throughout, follow one scenario: I change one cell in a 200 MB spreadsheet on my laptop, and it reaches my phone.

The simplest version is one app server. It stores each uploaded file whole in object storage, and its path and version in a database. Devices ask every 60 seconds whether anything changed.

File sync service · architecture

Stage 1: One server, whole files

DevicesApp serverwhole files, polls5100% busyObject storewhole filesMetadata DBfiles and journal1020% busy
Color keyServiceDatabaseQueue or streamBackground workerNew in this stageAsync or occasional

Stage 1 of 6

One server, whole files

Why this stage

Nothing yet.

This is the smallest design that syncs a folder between two devices.

What we add

An app server that stores each uploaded file whole in object storage and its path and version in a database.

Devices poll every 60 seconds.

What it costs

Every edit re-uploads the whole file, every byte passes through one server, and polls outnumber real requests.

At 170k metadata requests per second, Metadata servers is 5100% busy. Stage 2 is the fix.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one machine can do

  • One metadata server handles about 5,000 requests a second.
  • One MySQL or Postgres node handles about 100,000 indexed reads a second and 40,000 small commits.
  • A block server moves about 1 GB a second. That is about 2,500 block requests at 400 KB each.

How busy it gets

  • There are about 0.6 block requests for every metadata request.
  • Polling every 60 seconds adds about 5 requests for every real one at peak.

Notify servers

  • Notify servers are sized by connections, not requests.
  • About 50 million devices are online at peak. One server holds up to 500,000 long polls.

It breaks on bytes. A one-cell edit re-uploads the whole 200 MB file, and at 35,000 commits a second the peak upload would be 14 GB/s, far past what one server moves.

A 200 MB file over a home connection takes about 27 seconds to upload. For a one-cell change, that’s absurd. So the client splits each file into blocks of about 1.25 MB, and names each block by its SHA-256 hash, a fingerprint of its bytes. A file is then just its blocklist: the ordered list of its block hashes. Change one cell, and one block changes. Upload that one block.

Naming blocks by content has other gifts. Identical files share every block, so they’re stored once. And a block never changes, so any copy of it anywhere is correct, and can be cached forever.

Block servers move bytes, storing each block in object storage under its hash. Metadata servers never touch bytes: they record versions. A block index remembers which hashes are stored.

File sync service · architecture

Stage 2: Blocks and metadata, split

DevicesLoad balancernewBlock serversPUT and GET by hashnew68 servers, 60% busyMetadata serverscommit, list changes352 servers, 60% busyBlock indexhash → locationnewBlock storeS3, keyed by hashGC workermark and sweepnewMetadata DBfiles and journal1020% busy
Color keyServiceDatabaseQueue or streamBackground workerNew in this stageAsync or occasional
Stage

Stage 2 of 6

Blocks and metadata, split

Why this stage

A one-byte edit to a 200 MB file re-uploads 200 MB, and at 35,000 commits/s the peak upload is 14 GB/s, far past what one server moves.

What we add

Files split into content-addressed blocks.

Block servers move bytes; metadata servers record versions as lists of block hashes, committed only after every block is stored.

A garbage collector removes blocks nothing points at.

What it costs

Two services instead of one, a commit protocol with a retry, and orphaned blocks to collect.

At 170k metadata requests per second, Metadata shards is 1020% busy. Stage 3 is the fix.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one machine can do

  • One metadata server handles about 5,000 requests a second.
  • One MySQL or Postgres node handles about 100,000 indexed reads a second and 40,000 small commits.
  • A block server moves about 1 GB a second. That is about 2,500 block requests at 400 KB each.

How busy it gets

  • There are about 0.6 block requests for every metadata request.
  • Polling every 60 seconds adds about 5 requests for every real one at peak.

Notify servers

  • Notify servers are sized by connections, not requests.
  • About 50 million devices are online at peak. One server holds up to 500,000 long polls.

Two services now share one file’s state, so the order of their writes matters. The client asks to commit a new version, sending the blocklist. The server answers “first send me these blocks”, for any it doesn’t have. The client uploads them and asks again.

Saving an edit uploads only the block that changed
Block storeBlock serverMetadata serverLaptopBlock storeBlock serverMetadata serverLaptoprechunk and hash, 1 new chunk out of 160commit(path, parent rev 41, 160 hashes)422, missing block 7f3aPUT /blocks/7f3a, 1.2 MBverify SHA-256, store in 2 zones201, receipt recordedsame commit againappend entry 42 only if the path is still at 41201, rev 42Block storeBlock serverMetadata serverLaptopBlock storeBlock serverMetadata serverLaptoprechunk and hash, 1 new chunk out of 160commit(path, parent rev 41, 160 hashes)422, missing block 7f3aPUT /blocks/7f3a, 1.2 MBverify SHA-256, store in 2 zones201, receipt recordedsame commit againappend entry 42 only if the path is still at 41201, rev 42
Scroll or pinch to zoom, drag to pan
  • Color key
  • Database
  • Service

The order is the promise: blocks first, metadata last. A block stored but never committed costs a little space until it’s cleaned up. A version naming a block that was never stored is a corrupt file on every device that syncs it. So no commit succeeds until every block it names is stored.

Resuming a big upload is easy too: ask the commit again what’s missing. The stored blocks are the progress.

Devices still need to learn what changed, in order, and two people may save the same file.

The unit of sync is a namespace: each user’s private root is one, and each shared folder is another, mounted into every member’s tree. Each namespace has a journal, a list of changes numbered 1, 2, 3 with no gaps. A file’s revision is the journal number of the change that wrote it.

The commit is conditional: “record this new version, only if the file is still at revision 41”. If it is, the server appends entry 42. If someone else already made 42, the commit fails, and the client saves its edit as a conflicted copy. A stress test covers that.

CommitService.javacommit_service.pyCommitService.cscommit_service.gocommit-service.ts
// What a commit can answer. The client acts on each: done, upload these blocks and retry,
// or save its edit as a conflicted copy.
sealed interface CommitResult permits Committed, NeedBlocks, Conflict {}
record Committed(long rev) implements CommitResult {}
record NeedBlocks(List<String> missing) implements CommitResult {}
record Conflict(long currentRev) implements CommitResult {}
// One version of a file: its blocklist (SHA-256 of each chunk, in order) and who wrote it.
record FileVersion(long rev, List<String> blocks, String deviceId) {}
interface MetadataStore {
FileVersion latest(long ns, String path); // null if no file lives at this path now
// One transaction on the namespace's shard: if the path still points at expectedRev,
// append a journal entry and point the path at it. Returns the new rev (the entry's
// journal id), or -1 if someone else committed first.
long appendIfLatest(long ns, String path, long expectedRev, List<String> blocks, String deviceId);
}
interface Receipts {
// Written by a block server after it has the bytes and checked their hash. Kept for a
// day, which is well inside the garbage collector's grace period.
boolean has(long userId, String hash);
}
interface ChangeFeed {
void publish(long ns, long rev); // a hint for notify servers, not the data itself
}
final class CommitService {
private final MetadataStore store;
private final Receipts receipts;
private final ChangeFeed feed;
CommitService(MetadataStore store, Receipts receipts, ChangeFeed feed) {
this.store = store;
this.receipts = receipts;
this.feed = feed;
}
CommitResult commit(long userId, String deviceId, long ns, String path,
long parentRev, List<String> blocks) {
FileVersion cur = store.latest(ns, path);
long curRev = cur == null ? 0 : cur.rev();
if (curRev != parentRev) {
// Our own earlier commit whose response was lost: same device, same content.
// Answering "conflict" here would make a conflicted copy of the user's own save.
if (cur != null && cur.deviceId().equals(deviceId) && cur.blocks().equals(blocks))
return new Committed(cur.rev());
return new Conflict(curRev); // never overwrite an edit this device has not seen
}
// A block counts as present only if this user can prove they hold it: it is in the
// version they are editing, or they uploaded it recently. A hash alone is not proof,
// or anyone who learns a hash could commit it and then download the file.
Set<String> held = cur == null ? Set.of() : new HashSet<>(cur.blocks());
List<String> missing = blocks.stream().distinct()
.filter(h -> !held.contains(h) && !receipts.has(userId, h))
.toList();
if (!missing.isEmpty()) return new NeedBlocks(missing); // blocks first, metadata last
// The check above ran outside the transaction, so a concurrent commit can still win.
// The conditional append is what makes the version change atomic.
long rev = store.appendIfLatest(ns, path, parentRev, blocks, deviceId);
if (rev < 0) {
FileVersion now = store.latest(ns, path);
return new Conflict(now == null ? 0 : now.rev());
}
// After the commit, never before: a device woken early would find nothing new. If
// this publish is lost, the device's long poll times out and it pulls anyway.
feed.publish(ns, rev);
return new Committed(rev);
}
}

The code also checks for a retry. If a commit’s response was lost, and the same device retries the same change, it gets the original answer back, not a conflict with its own save.

The journal also tells us how to split the metadata. 500 TB of metadata is a few hundred times what one database holds comfortably. So we split it by namespace. Everything a commit touches lives in one namespace: the file’s current version, the journal, the next number. So each commit is a plain transaction on one shard. 256 shards of about 2 TB each, with replicas in other zones. Splitting by user would break shared folders. Splitting by file would break the ordered journal.

File sync service · architecture

Stage 3: Shard metadata by namespace

DevicesLoad balancerBlock serversPUT and GET by hash68 servers, 60% busyMetadata serverscommit, list changes352 servers, 60% busyBlock indexhash → locationBlock storeS3, keyed by hashGC workermark and sweepMetadata shards256, by namespace<1% busyShard replicasdelta readsnew2% busy
Color keyServiceDatabaseQueue or streamBackground workerNew in this stageAsync or occasional
Stage

Stage 3 of 6

Shard metadata by namespace

Why this stage

At the 170,000 requests/s peak one database is past 100% on reads, and the metadata is about 500 TB, a few hundred times what one node holds comfortably.

What we add

Metadata split across 256 shards by namespace, each with a primary and 2 replicas.

Delta reads go to a replica that has caught up to the cursor.

What it costs

No transaction across namespaces, so a move between a shared folder and a private one is two commits.

At 170k metadata requests per second, every part has headroom. Raise the traffic until something turns red.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one machine can do

  • One metadata server handles about 5,000 requests a second.
  • One MySQL or Postgres node handles about 100,000 indexed reads a second and 40,000 small commits.
  • A block server moves about 1 GB a second. That is about 2,500 block requests at 400 KB each.

How busy it gets

  • There are about 0.6 block requests for every metadata request.
  • Polling every 60 seconds adds about 5 requests for every real one at peak.

Notify servers

  • Notify servers are sized by connections, not requests.
  • About 50 million devices are online at peak. One server holds up to 500,000 long polls.
Go deeperOther ways to split metadata, and reading from replicas1 min
Split by One commit on one shard? Ordered journal per folder? Problem
Namespace Yes Yes One huge team folder is one hot shard
User Not for shared folders No A shared folder would live on every member’s shard
Hash of path Yes, for one file No “Everything after my cursor” asks every shard

The block index is split by hash instead, because one block can belong to many folders.

Change lists are read from replicas, with one rule: skip any replica that is behind the device’s cursor or the number in the nudge. So a folder never appears to go backwards on a slow replica. Dropbox kept its file metadata on sharded MySQL for years, later behind its own layer, Edgestore.

Polling every 60 seconds misses the 5-second promise, and costs 830,000 reads a second that mostly say “nothing new”. So each device sends a long poll: a request with its cursor that a notify server holds open for up to 3 minutes. After every commit, the metadata server publishes “folder 7 is now at 42”. Notify servers holding polls for folder 7 answer “changes: true”, and each device pulls the new entries.

The nudge carries no file data. So it can be lost or repeated without harm: the worst case is a device that learns a little late, at the next poll. The journal stays the only truth.

File sync service · architecture

Stage 4: Push changes with long polls

DevicesLoad balancerBlock serversPUT and GET by hash68 servers, 60% busyMetadata serverscommit, list changes68 servers, 60% busyNotify serversheld long pollsnew165 servers, 60% busyBlock indexhash → locationPub/subone channel per nsnewBlock storeS3, keyed by hashGC workermark and sweepMetadata shards256, by namespace<1% busyShard replicasdelta reads<1% busy
Color keyServiceDatabaseQueue or streamBackground workerNew in this stageAsync or occasional
Stage

Stage 4 of 6

Push changes with long polls

Why this stage

About 50 million devices polling every 60 seconds send 830,000 requests/s that almost always answer "nothing new", about 5 for every real request, and a change still takes 30 seconds on average to show up.

What we add

Notify servers that hold one long poll per device, and a pub/sub channel per namespace that the metadata servers publish to after each commit.

What it costs

About 170 servers whose only job is to hold idle connections, and a notification that can be lost, so the poll needs a timeout.

At 170k metadata requests per second, every part has headroom. Raise the traffic until something turns red.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one machine can do

  • One metadata server handles about 5,000 requests a second.
  • One MySQL or Postgres node handles about 100,000 indexed reads a second and 40,000 small commits.
  • A block server moves about 1 GB a second. That is about 2,500 block requests at 400 KB each.

How busy it gets

  • There are about 0.6 block requests for every metadata request.
  • Polling every 60 seconds adds about 5 requests for every real one at peak.

Notify servers

  • Notify servers are sized by connections, not requests.
  • About 50 million devices are online at peak. One server holds up to 500,000 long polls.

Follow one edit, and its pickup on another laptop:

File sync service · architecture

Trace a request: Save a small edit to a 200 MB file

DevicesLoad balancerBlock serversPUT and GET by hashMetadata serverscommit, list changesBlock indexhash → locationBlock storeS3, keyed by hashGC workermark and sweepMetadata shards256, by namespaceShard replicasdelta reads
Color keyServiceDatabaseQueue or streamBackground workerNew in this stageAsync or occasionalHop in motionHops doneResponse

Save a small edit to a 200 MB file: step 1 of 12

Devices to Load balancer

The client has rechunked the file and found one new chunk. It sends commit(path, parent rev 41, 160 block hashes), about 5 KB, one round trip from a nearby region.

30 ms of a 500 ms budget
  1. Devices → Load balancer30 ms
  2. Load balancer → Metadata servers1 ms
  3. Metadata servers → Metadata shards3 ms
  4. Devices → Load balancer160 ms
  5. Load balancer → Block servers1 ms
  6. Block servers → Block store40 ms
  7. Block servers → Block index3 ms
  8. Devices → Load balancer30 ms
  9. Load balancer → Metadata servers1 ms
  10. Metadata servers → Metadata shards6 ms
  11. Metadata servers → Pub/subasync
  12. Response
Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.

The commit takes about 275 ms, nearly all of it the one block upload. The other laptop has the edit about 220 ms after the commit.

This is enough for a mid-level answer. Hash-named blocks, blocks before metadata, a conditional commit to a journal per folder, long polls, and sharding by folder. The stress tests are what make it a senior answer.

Go deeperOther ways to tell a device something changed1 min
Option Delay Cost at 50 million devices Problem
Polling every 60 s 30 s on average 830,000 requests a second, mostly “nothing” Slow and expensive; faster polling is worse
Long polling Under a second 50 million idle connections A lost nudge waits for the timeout
WebSockets Under a second The same connections; some proxies break them Two-way, which sync doesn’t need
Mobile push Seconds, best effort Free to us Throttled and dropped; fine for phones only

Plain HTTPS passes every company proxy, and sync only needs “something changed”, so a two-way socket buys nothing. Re-arming every 3 minutes costs about 280,000 tiny requests a second, answered from the notify servers’ memory. When a busy shared folder changes often, nudges are combined to at most one per second per device.

Go deeperThe API, the tables, and the subscribe race3 min
POST /2/files/commit
Idempotency-Key: 9a41c0e2-...
{ "namespace_id": 7, "path": "/reports/q3.xlsx", "parent_rev": 41, "blocks": ["7f3a...", "..."] }
201 Created { "rev": 42, "cursor": "ns7:42" }
422 { "missing_blocks": ["7f3a..."] } upload these, then retry the same call
409 Conflict { "current_rev": 43 } save the edit as a conflicted copy

PUT /blocks/{hash} stores up to 4 MB; the server checks the bytes really hash to the name. A download needs a short-lived token from a metadata response, because knowing a hash is not permission to read it.

CREATE TABLE journal (
ns_id BIGINT NOT NULL,
jid BIGINT NOT NULL, -- 1, 2, 3... per namespace, no gaps
path TEXT NOT NULL,
blocks BYTEA, -- hashes in order; null means deleted
PRIMARY KEY (ns_id, jid)
);
CREATE TABLE files_latest ( -- updated in the same transaction as the journal
ns_id BIGINT, path_lower TEXT, rev BIGINT,
PRIMARY KEY (ns_id, path_lower)
);

The subscribe race. A commit could land between a device’s last pull and its long poll reaching a notify server. The cursor in the poll closes that gap: the notify server subscribes first, then reads the folder’s latest number, and answers at once if the device is behind.

The cursor in the long poll closes the subscribe race
Metadata serverPub/subNotify serverLaptopMetadata serverPub/subNotify serverLaptopthe phone commits entry 42 and it is publishedlist_folder/continue, cursor ns7 at 41nothing new, cursor ns7 at 41longpoll, cursor ns7 at 41subscribe to ns7, then read its latest idlatest is 42changes true, at oncelist_folder/continue, cursor ns7 at 41entry 42, cursor ns7 at 42Metadata serverPub/subNotify serverLaptopMetadata serverPub/subNotify serverLaptopthe phone commits entry 42 and it is publishedlist_folder/continue, cursor ns7 at 41nothing new, cursor ns7 at 41longpoll, cursor ns7 at 41subscribe to ns7, then read its latest idlatest is 42changes true, at oncelist_folder/continue, cursor ns7 at 41entry 42, cursor ns7 at 42
Scroll or pinch to zoom, drag to pan
  • Color key
  • Service
  • Queue or stream

Phones in the background use mobile push instead of a held connection, because the operating system suspends background apps.

The design is complete. Now we test it where file sync is hard: where to cut a file, deleting blocks without losing data, conflicting edits, a folder with 10 million files, and the storage bill. Only the last one adds boxes.

Interviewerminute 20

You split files into 4 MB blocks. I insert one byte at the start of a 1 GB file. What uploads?

What they are testing

Whether you know why fixed-size blocks fail on an insert, and how cutting by content fixes it.

With fixed 4 MB blocks, inserting one byte at the start shifts every later byte by one. So every block’s content changes, and all 256 blocks upload. Changes in place are fine. Inserts and deletes are not.

Content-defined chunking puts the cuts where the content says. A rolling hash runs over the bytes, looking at the last 32 bytes at each step. Wherever that hash matches a pattern, the block ends. An insert changes the hash only near itself. So only the block containing the insert changes, and every later cut is found again at the same content.

Our blocks are at least 256 KB, about 1.25 MB on average, and at most 4 MB. The minimum stops runs of tiny blocks, and the maximum limits memory and the size of a retried upload. The rolling hash costs one shift and one add per byte, less than the SHA-256 every block needs anyway.

Chunker.javachunker.pyChunker.csmain.goexample.ts
// Content-defined chunking: cut where the bytes say so, not at fixed offsets, so inserting a
// byte near the start of a file moves one boundary instead of every boundary after it.
final class Chunker {
static final int MIN = 256 * 1024; // no cut before this: tiny chunks bloat the index
static final int MAX = 4 * 1024 * 1024; // always cut here: bounds memory and retry size
// Cut when the top 20 bits of the hash are zero, about once every 2^20 bytes past MIN,
// so chunks average about 1.25 MB. Top bits, because they depend on the last 32 bytes.
static final int MASK = 0xFFFFF000;
// The gear table is part of the protocol: every client must cut in the same places, or
// the same file chunks differently on two laptops and nothing deduplicates.
static final int[] GEAR = gearTable(0x9E3779B9);
static int[] gearTable(int seed) {
int[] t = new int[256];
int x = seed;
for (int i = 0; i < 256; i++) { // xorshift32: same numbers in every language
x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
t[i] = x;
}
return t;
}
// Returns the end offset of each chunk. The caller hashes each chunk with SHA-256 to get
// the block's name. Production streams the file; an array keeps the idea visible.
static List<Integer> boundaries(byte[] data) {
List<Integer> ends = new ArrayList<>();
int start = 0;
while (start < data.length) {
int end = Math.min(start + MAX, data.length);
int cut = end;
if (data.length - start > MIN) {
int h = 0;
for (int i = start + MIN; i < end; i++) {
// Gear hash: one shift and one add per byte, so hashing costs less than
// the SHA-256 that follows. Old bytes shift out after 32 steps.
h = (h << 1) + GEAR[data[i] & 0xFF];
if ((h & MASK) == 0) { cut = i + 1; break; }
}
}
ends.add(cut);
start = cut;
}
return ends;
}
}

Run on 20 MB of random bytes with one byte inserted at 5 MB, this changes 1 of 20 blocks. Fixed blocks change every block after the insert.

Go deeperOther ways to cut, and why the cut rules are frozen1 min
Approach One-byte insert in 1 GB uploads Costs
Whole file 1 GB Nothing to build
Fixed 4 MB blocks 1 GB Simple; in-place edits are cheap
Fixed blocks plus rsync Kilobytes Needs the old block on both ends
Content-defined blocks 1 to 2 MB A rolling hash per byte; variable sizes

Dropbox itself uses fixed blocks of up to 4 MB, which are simpler and handle appends and in-place edits well. We pay for a rolling hash to also keep inserts cheap.

The hash table and thresholds are part of the protocol. If two client versions cut differently, identical files stop sharing blocks, silently. So changing them is a versioned rollout, and old blocklists stay valid forever.

Uploads fail halfway, files get deleted, and two users upload the same file. How do you know a block is safe to delete, and how does sharing blocks not leak data?

Abandoned uploads, expired versions and deleted files all leave blocks nobody needs. Deleting a block that’s still in use is data loss. There’s no transaction across metadata and blocks, so we need a method that fails safe.

Reference counts sound natural: add one on commit, subtract one when a version expires. But a lost subtraction leaks a block, and a doubled one deletes live data. So we use mark and sweep. Weekly, a collector reads every version still within history, and builds the set of live hashes. Then it sweeps the block index and deletes old blocks that aren’t in the set. A mistake keeps garbage. It never deletes live data.

The danger is a race: a block that looks dead, while a new commit makes it live again. Three rules close it:

Deleting a block without racing a commit

1. Every upload touches the block

Uploading a block that already exists stores nothing new, but updates its last_touched time and writes a 24-hour receipt for this user. A commit may only use blocks from its parent version, a version still in history, or a receipt.

2. Only old, unused blocks are candidates

A block can be deleted only if it’s not in the live set, and was last touched more than 7 days before the mark began. A 24-hour receipt expires long before that.

3. Delete with a compare-and-set

The sweep marks the block deleting only if last_touched is unchanged, so a concurrent upload wins. An upload that finds a block marked deleting stores the bytes again.

Sharing blocks that doesn’t leak. Storing each block once across all users saves 25% of bytes. But letting a client skip uploading any hash that exists anywhere is dangerous. A hash becomes a download ticket for someone else’s file, which a 2011 tool exploited against Dropbox. And “already have it” tells anyone whether a given file exists on the service. So a hash without bytes is accepted only if this user provably holds the block. Otherwise the client uploads it, and we discard the copy if it’s already stored. The answer looks the same either way. Storage is shared across everyone. Bandwidth savings only come from what the user already has.

Go deeperOther ways to clean up, and why the race can't slip through1 min
Option How it works Fails how
Never delete Keep every block forever Storage grows by every abandoned upload
Reference counts Add one on commit, subtract when a version ends A lost subtraction leaks; a doubled one loses data
Mark and sweep, with a guard Find live blocks, delete old ones not in the set Costs a full scan; garbage waits a week

Why a commit can’t slip through: any block a commit names is either in a version within history, which the mark saw, or covered by a receipt, which means it was touched in the last 24 hours and isn’t a candidate. For end-to-end encrypted folders, even shared storage leaks information, so it’s turned off there.

My laptop was offline on a plane. I edited a report, and my colleague edited the same report from the office. I land. What happens?

The commit already detects the conflict. Both edits started from revision 41. The office made 42. The laptop’s commit, “only if still at 41”, fails. What to do next is a choice:

Option Result Problem
Last writer wins, by clock One edit vanishes Silent data loss, and laptop clocks disagree by minutes
Merge automatically One file with both edits Impossible for most formats a storage service can’t read
Keep both: a conflicted copy report (Ana's conflicted copy).docx The user merges by hand

We keep both. The server’s version stays at the path, and the laptop commits its edit as a new file beside it. Nothing is lost, and nothing is guessed.

Two edits from rev 41: one wins the path, the other becomes a copy
Laptop, offlineMetadata serverOffice desktopLaptop, offlineMetadata serverOffice desktopboth have report.docx at rev 41edit offline, parent stays rev 41commit report.docx, parent 41201, rev 42back online, commit report.docx, parent 41409, current rev 42commit the conflicted copy as a new file201, rev 43list changes after its cursorrev 42 of report.docx, which it downloadsLaptop, offlineMetadata serverOffice desktopLaptop, offlineMetadata serverOffice desktopboth have report.docx at rev 41edit offline, parent stays rev 41commit report.docx, parent 41201, rev 42back online, commit report.docx, parent 41409, current rev 42commit the conflicted copy as a new file201, rev 43list changes after its cursorrev 42 of report.docx, which it downloads
Scroll or pinch to zoom, drag to pan
  • Color key
  • Service

Offline edits need a common starting point. To tell “I deleted this file” from “this file was created elsewhere while I was away”, comparing the local folder with the server isn’t enough. Dropbox’s sync engine keeps three trees: remote (what the server says), local (what’s on disk) and synced (the last state both agreed on). A file in synced but missing on disk was deleted locally. A file on the server but not in synced is new from elsewhere.

Go deeperEdits that aren't plain edits1 min
Local change Remote change Result
Edit Edit The server’s version stays; the local edit becomes a conflicted copy
Edit Delete The edit wins: the file comes back, because a delete is recoverable
Delete Edit The edit wins: the file returns on this device
Rename Edit Both apply: the new name and the new content
Create a.txt Create a.txt Same blocks: nothing to do. Different: a conflicted copy

Why not vector clocks? They detect concurrent edits when no single server orders them. Here the journal already orders every commit per folder, and the parent revision detects the conflict exactly. Vector clocks would add bytes to every version and still not answer which edit to keep.

A company’s team folder has 10 million files and 50,000 members. Someone renames the top folder. What happens?

If metadata is keyed by path, renaming /Team to /Team-2026 changes the key of 10 million rows, and writes 10 million journal entries. Every member’s device must replay each one.

So files get stable IDs with parent pointers, like the tree in the in-memory file system. Each row is (folder, file ID, parent ID, name). A rename is one row change and one journal entry: “folder X moved”. Each client applies that to its own tree. The cost: looking up a path walks it segment by segment, through a cache of folder IDs, and “list everything under /Team” becomes a tree walk instead of a range scan.

Three more rules keep a giant folder from hurting everyone else. Listings come in pages of a few thousand. A new device’s first sync is a paged listing of the current state, not a replay of the journal from entry 1. And old journal entries are folded into the current state, so the journal doesn’t grow forever.

We are adding over 100 PB a year. What does that cost, and what would we change?

Three copies spend 3 bytes of disk per byte, and most bytes are cold after their first weeks. So we use erasure coding: each block is split into 6 data pieces plus 3 extra parity pieces, and any 6 of the 9 can rebuild it. That survives losing any 3 pieces, with 1.5 bytes of disk per byte, half of three copies.

New blocks are written as plain copies, because hot data is cheaper to write and read that way. A background pass re-encodes them after a few days. Blocks unread for months move to denser, slower disks. The garbage collector already walks every block, so it does the moves.

File sync service · architecture

Stage 5: Storage that pays for itself

DevicesLoad balancerBlock serversPUT and GET by hash68 servers, 60% busyMetadata serverscommit, list changes68 servers, 60% busyNotify serversheld long polls165 servers, 60% busyBlock indexhash → locationPub/subone channel per nsBlock storeerasure coded, tieredGC workersweep + cold tieringMetadata shards256, by namespace<1% busyShard replicasdelta reads<1% busy
Color keyServiceDatabaseQueue or streamBackground workerNew in this stageAsync or occasional
Stage

Stage 5 of 6

Storage that pays for itself

Why this stage

About 110 PB of new unique data a year.

Three full copies of every block, all on the hottest tier, make storage the largest line on the bill by far.

What we add

Erasure coding instead of three copies, and a cold tier for blocks nobody has read in months.

The garbage collector also moves blocks between tiers.

What it costs

Slower first reads of cold files, and rebuilding a lost disk reads several others.

At 170k metadata requests per second, every part has headroom. Raise the traffic until something turns red.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one machine can do

  • One metadata server handles about 5,000 requests a second.
  • One MySQL or Postgres node handles about 100,000 indexed reads a second and 40,000 small commits.
  • A block server moves about 1 GB a second. That is about 2,500 block requests at 400 KB each.

How busy it gets

  • There are about 0.6 block requests for every metadata request.
  • Polling every 60 seconds adds about 5 requests for every real one at peak.

Notify servers

  • Notify servers are sized by connections, not requests.
  • About 50 million devices are online at peak. One server holds up to 500,000 long polls.

Dropbox moved most of its users’ data off S3 in 2016, onto its own system, Magic Pocket, which works this way. Below roughly 50 PB, the honest answer is a cloud object store with its cold storage classes. Owning hardware is a multi-year commitment.

Go deeperStorage options side by side1 min
Option Disk per byte Gains Costs
Three copies 3.0 Simple; any one copy serves a read Twice the disks
Erasure coding 6+3, after a few days 1.5 Same loss tolerance, half the disks A damaged read touches 6 pieces
Cloud storage classes In the price No storage team At hundreds of PB, the margin is the main cost
Own hardware 1.5 or less Lowest cost per byte at this size A storage engineering team for years

Opening a cold file takes noticeably longer, and a lost disk is rebuilt by reading several others.

Interviewerminute 40

The metadata shard for a big shared folder fails over. What do its devices see?

What they are testing

Whether devices pause and resume from their place in the journal, with nothing lost or applied twice.

First, the local promise: files already on a device always open, whatever the server is doing. A server outage pauses sync, never work.

A metadata shard fails over. Its primary dies, and a replica in another zone is promoted in about 30 seconds. For that half minute, commits to the folders on that shard fail, and devices retry them. Blocks were uploaded first, so a retried commit only records a version whose blocks are already safe, and the same commit sent twice is recorded once. Long polls drop and reconnect. Each device then asks for everything after its cursor, so it picks up exactly where it stopped. Nobody loses an edit; they see sync pause, then catch up.

A whole region goes dark. Eleven nines of durability can’t rest on one region. So every block is copied to a second region in the background, and each metadata shard keeps a standby there. Here the first rule applies again. Metadata is smaller, so it usually arrives first. A standby that applied a journal entry before its blocks arrived would, after failover, list files it can’t serve. So the standby applies an entry only once every block it names is present. On failover, it loses at most the last few seconds of commits, and never holds a version it can’t serve.

These add the last boxes to the drawing: a second region with its own copy of every block, and a standby for each metadata shard.

File sync service · architecture

Stage 6: A second region

Region BEvery block, copied async.A metadata standby applieseach entry only once itsblocks have arrived.DevicesLoad balancerBlock serversPUT and GET by hash68 servers, 60% busyMetadata serverscommit, list changes68 servers, 60% busyNotify serversheld long polls165 servers, 60% busyBlock indexhash → locationPub/subone channel per nsBlock storein 2 regionsGC workersweep + cold tieringMetadata shardsstandby in region B<1% busyShard replicasdelta reads<1% busy
Color keyServiceDatabaseQueue or streamBackground workerNew in this stageAsync or occasional
Stage

Stage 6 of 6

A second region

Why this stage

Eleven nines of durability cannot rest on one region, and one regional outage of an hour uses a quarter of a 99.95% yearly budget.

What we add

Every block copied to a second region, and a standby metadata copy there that only applies a journal entry once its blocks have arrived.

What it costs

Double the storage network bill, and a failover can lose the last few seconds of commits, which devices then upload again.

At 170k metadata requests per second, every part has headroom. Raise the traffic until something turns red.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
The numbers behind the busy bars

What one machine can do

  • One metadata server handles about 5,000 requests a second.
  • One MySQL or Postgres node handles about 100,000 indexed reads a second and 40,000 small commits.
  • A block server moves about 1 GB a second. That is about 2,500 block requests at 400 KB each.

How busy it gets

  • There are about 0.6 block requests for every metadata request.
  • Polling every 60 seconds adds about 5 requests for every real one at peak.

Notify servers

  • Notify servers are sized by connections, not requests.
  • About 50 million devices are online at peak. One server holds up to 500,000 long polls.

Devices recover those lost seconds themselves. Promotion bumps the shard’s epoch, a number that says which primary is in charge. Every cursor carries its epoch. A device with an old-epoch cursor past the restart point is told to reset: it compares its files with the server’s listing, and commits again whatever the server lost. The same epoch fences the old primary if it comes back.

Click any box to break it and see what users would notice:

File sync service · architecture

Break it: what fails, who notices, what users see

Region BEvery block, copied async.A metadata standby applieseach entry only once itsblocks have arrived.DevicesLoad balancerBlock serversPUT and GET by hashMetadata serverscommit, list changesNotify serversheld long pollsBlock indexhash → locationPub/subone channel per nsBlock storein 2 regionsGC workersweep + cold tieringMetadata shardsstandby in region BShard replicasdelta reads
Color keyServiceDatabaseQueue or streamBackground workerNew in this stageAsync or occasional
Break

Failure: click any box to break it

A metadata shard primary goes down

What breaks

Commits for the namespaces on that shard fail.

Other shards are unaffected.

How we notice

Commit error rate per shard.

What users see

About 1 in 256 users cannot save for about 30 seconds; their client queues the edit.

What we do

Promote the semi-synchronous replica, which has every acknowledged commit, and bump the shard epoch in the slot map so the old primary, if it returns, cannot accept commits.

Delta reads carry on from the replicas meanwhile.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.
Go deeperEvery failure, and what users see2 min
Part Failure What users see What we do
Metadata shard Primary down Commits to its folders pause about 30 s Replica takes over; clients retry safely
Notify server Crash Changes late by seconds for its devices Random reconnect delay; the cursor catches up
Pub/sub Down Changes late by up to the poll timeout Nudges are hints; shorten the poll timeout
Block index Shard down New uploads in its hash range retry Replicated shards
Block store Zone lost Nothing; slightly slower reads Pieces in 3 zones; rebuild
Garbage collector Stopped Nothing Resume; it only ever deletes re-checked blocks
Whole region Outage Sync pauses; local files keep working Fail over; clients re-commit the last seconds

Block uploads are safe to repeat, because the name is the content. Commits are safe to repeat, because a retry from the same device with the same blocklist is recognized. Under overload, stop thumbnails and indexing first, then lengthen long-poll timeouts, then slow first syncs of new devices. Commits and downloads go last.

Go deeperWhat users can see1 min
Operation Promise What a user can notice
Saving one file One at a time per folder, conditional Two saves at once: one wins, the other becomes a copy
Order of changes in a folder One order, numbered with no gaps Every device replays the same sequence
Order across folders None A file moved between folders can briefly show twice
The device that saved Sees its own save at once The commit returns the new cursor
Other devices Eventual, usually under a second A colleague sees the edit a moment later
Blocks Never change, checked by hash Any copy is correct, or detectably broken

“Eventual consistency” describes how fast copies agree, not how simultaneous saves are resolved. This system is strict at the commit, and eventual only in how fast other devices hear about it.

Go deeperRunning it: alerts and safety1 min

Wake someone up when: commit success (not counting conflicts and “send blocks”) drops below 99.9% on any shard; test device pairs see a change take more than 10 seconds to arrive; block errors pass 0.5%; the cross-region block backlog passes 15 minutes, because that backlog is what a region failover would lose; or an audit finds any block named by a live version but missing from storage.

Clients are the hardest thing to deploy. Installs run old versions for months, so the protocol only changes by adding a version. Moving a folder between shards is copy, replay the journal, pause its commits for under a second, then switch.

Here is everything we built, in the order we built it. Use the buttons to step through it.

File sync service · architecture

The full design, stage by stage

Region BEvery block, copied async.A metadata standby applieseach entry only once itsblocks have arrived.DevicesLoad balancerBlock serversPUT and GET by hash68 servers, 60% busyMetadata serverscommit, list changes68 servers, 60% busyNotify serversheld long polls165 servers, 60% busyBlock indexhash → locationPub/subone channel per nsBlock storein 2 regionsGC workersweep + cold tieringMetadata shardsstandby in region B<1% busyShard replicasdelta reads<1% busy
Color keyServiceDatabaseQueue or streamBackground workerNew in this stageAsync or occasional
Stage

Stage 6 of 6

A second region

Why this stage

Eleven nines of durability cannot rest on one region, and one regional outage of an hour uses a quarter of a 99.95% yearly budget.

What we add

Every block copied to a second region, and a standby metadata copy there that only applies a journal entry once its blocks have arrived.

What it costs

Double the storage network bill, and a failover can lose the last few seconds of commits, which devices then upload again.

At 170k metadata requests per second, every part has headroom. Raise the traffic until something turns red.

Pinch or tap the zoom buttons to read small labels.Drag any box to move it. Reset puts every box back.The whole design. Step through it: one server with whole files, blocks and metadata split, a journal per folder split into shards, long polls, cheaper storage, then a second region.
The numbers behind the busy bars

What one machine can do

  • One metadata server handles about 5,000 requests a second.
  • One MySQL or Postgres node handles about 100,000 indexed reads a second and 40,000 small commits.
  • A block server moves about 1 GB a second. That is about 2,500 block requests at 400 KB each.

How busy it gets

  • There are about 0.6 block requests for every metadata request.
  • Polling every 60 seconds adds about 5 requests for every real one at peak.

Notify servers

  • Notify servers are sized by connections, not requests.
  • About 50 million devices are online at peak. One server holds up to 500,000 long polls.
Decision We chose We gave up We’d choose differently if
Blocks Content-defined, named by SHA-256 The simplicity of fixed blocks Files were mostly edited in place
Commit Blocks first, conditional on the parent One-step uploads Files were small and never edited
Notification Long poll with a cursor; push on phones Two-way sockets We needed live presence or co-editing
Metadata Sharded SQL by folder Atomic moves across folders Cross-folder moves had to be atomic
Conflicts Conflicted copies Automatic merges We understood the file formats
Garbage Weekly mark and sweep, with a touch guard Prompt cleanup Storage were scarcer than safety
Storage Erasure coding and a cold tier Fast reads of cold files We stayed under about 50 PB
Regions Async copies, standby waits for blocks Zero data loss on failover Losing seconds of commits were unacceptable
  • 0 to 4. Scale, file size, sharing, history, and the conflict policy.
  • 4 to 9. 35,000 commits a second, 50 million devices online, 110 PB a year, 500 TB of metadata.
  • 9 to 20. Build it: hash-named blocks, blocks before metadata, the journal and conditional commit, sharding by folder, long polls. A mid-level answer can stop here.
  • 20 to 30. The stress tests: where to cut files, then deleting blocks safely.
  • 30 to 40. More stress tests: conflicts, then a huge folder or the storage bill.
  • 40 to 45. A region failing, then your own questions.

Never skip blocks before metadata, the conditional commit, or conflicted copies.

Interviewer asksA user restores a file from 20 days ago. What happens?

It’s a new commit whose blocklist is the old version’s. The blocks still exist, because the collector keeps everything named by any version within 30 days. Nothing uploads, and every device syncs it like any other change.

Interviewer asksHow would you add end-to-end encryption?

The client encrypts each block before hashing it, with a key the server never sees. Sharing blocks across users stops, because identical files now look different, and even sharing would reveal which files match. Previews and search move to the device.

Go deeperMore follow-up questions1 min

A file moves out of a shared folder into my private one. Is that atomic? No. The two folders can live on different shards, so the move is two commits: add in the destination, delete in the source. Clients apply adds before deletes, so the file can briefly appear twice, but never disappears.

How do phones save battery? They don’t hold a long poll in the background. Mobile push wakes them for changes, and they download files only when opened. A phone keeps a list of the folder, not a full copy.