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.
“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.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains each part.
- 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.
- 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.
- 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.
- 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.
- Devices wait on a long poll. The server answers “something changed” the moment it does, and the device pulls the changes.
Scope it
Section titled “Scope it”A few questions decide how much we store, and what happens when edits collide.
Design Dropbox. A folder that stays in sync across a user’s devices.
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 |
The numbers that decide it
Section titled “The numbers that decide it”Now we work out the numbers. Each one should change a decision.
- 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
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.
Each change wakes other devices and folder members, who pull it: about 5 reads per change.
So we reads are past one database.
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.
With block-level sync, a change uploads about 400 KB of new data.
So we bytes get their own servers, away from metadata.
After removing the 25% of data that’s already stored somewhere.
So we storage is the biggest bill.
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.
- 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.
Build the design
Section titled “Build the design”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.
Step 1. One server, whole files
Section titled “Step 1. One server, whole files”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.
Stage 1: One server, whole files
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.
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.
Step 2. Blocks named by their hash
Section titled “Step 2. Blocks named by their hash”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.
Stage 2: Blocks and metadata, split
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.
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.
Step 3. Blocks first, then the version
Section titled “Step 3. Blocks first, then the version”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.
- 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.
Step 4. One journal per folder
Section titled “Step 4. One journal per folder”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.
// 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); }}@dataclass(frozen=True)class Committed: rev: int
@dataclass(frozen=True)class NeedBlocks: missing: list[str]
@dataclass(frozen=True)class Conflict: current_rev: int
# What a commit can answer. The client acts on each: done, upload these blocks and retry,# or save its edit as a conflicted copy.CommitResult = Committed | NeedBlocks | Conflict
# One version of a file: its blocklist (SHA-256 of each chunk, in order) and who wrote it.@dataclass(frozen=True)class FileVersion: rev: int blocks: list[str] device_id: str
class MetadataStore(Protocol): def latest(self, ns: int, path: str) -> FileVersion | None: ... # None: no file here now
# One transaction on the namespace's shard: if the path still points at expected_rev, # append a journal entry and point the path at it. Returns the new rev (the entry's # journal id), or None if someone else committed first. def append_if_latest( self, ns: int, path: str, expected_rev: int, blocks: list[str], device_id: str ) -> int | None: ...
class Receipts(Protocol): # 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. def has(self, user_id: int, block_hash: str) -> bool: ...
class ChangeFeed(Protocol): def publish(self, ns: int, rev: int) -> None: ... # a hint for notify servers, not the data
class CommitService: def __init__(self, store: MetadataStore, receipts: Receipts, feed: ChangeFeed): self._store = store self._receipts = receipts self._feed = feed
def commit( self, user_id: int, device_id: str, ns: int, path: str, parent_rev: int, blocks: list[str] ) -> CommitResult: cur = self._store.latest(ns, path) cur_rev = cur.rev if cur else 0 if cur_rev != parent_rev: # 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 and cur.device_id == device_id and cur.blocks == blocks: return Committed(cur.rev) return Conflict(cur_rev) # 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. held = set(cur.blocks) if cur else set() missing = [ h for h in dict.fromkeys(blocks) # distinct, in order if h not in held and not self._receipts.has(user_id, h) ] if missing: return 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. rev = self._store.append_if_latest(ns, path, parent_rev, blocks, device_id) if rev is None: now = self._store.latest(ns, path) return Conflict(now.rev if now else 0) # 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. self._feed.publish(ns, rev) return Committed(rev)// What a commit can answer. The client acts on each: done, upload these blocks and retry,// or save its edit as a conflicted copy.abstract record CommitResult;sealed record Committed(long Rev) : CommitResult;sealed record NeedBlocks(IReadOnlyList<string> Missing) : CommitResult;sealed record Conflict(long CurrentRev) : CommitResult;
// One version of a file: its blocklist (SHA-256 of each chunk, in order) and who wrote it.sealed record FileVersion(long Rev, IReadOnlyList<string> Blocks, string DeviceId);
interface IMetadataStore{ 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 null if someone else committed first. long? AppendIfLatest(long ns, string path, long expectedRev, IReadOnlyList<string> blocks, string deviceId);}
interface IReceipts{ // 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. bool Has(long userId, string hash);}
interface IChangeFeed{ void Publish(long ns, long rev); // a hint for notify servers, not the data itself}
sealed class CommitService(IMetadataStore store, IReceipts receipts, IChangeFeed feed){ public CommitResult Commit(long userId, string deviceId, long ns, string path, long parentRev, IReadOnlyList<string> blocks) { var cur = store.Latest(ns, path); long curRev = cur?.Rev ?? 0; 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 is not null && cur.DeviceId == deviceId && cur.Blocks.SequenceEqual(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. var held = cur is null ? new HashSet<string>() : new HashSet<string>(cur.Blocks); var missing = blocks.Distinct() .Where(h => !held.Contains(h) && !receipts.Has(userId, h)) .ToList(); if (missing.Count > 0) 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. var rev = store.AppendIfLatest(ns, path, parentRev, blocks, deviceId); if (rev is null) return new Conflict(store.Latest(ns, path)?.Rev ?? 0);
// 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.Value); return new Committed(rev.Value); }}// Outcome is what a commit can answer. The client acts on each: done, upload the missing// blocks and retry, or save its edit as a conflicted copy.type Outcome int
const ( Committed Outcome = iota NeedBlocks Conflict)
type CommitResult struct { Outcome Outcome Rev int64 // the new rev if Committed, the server's current rev if Conflict Missing []string // if NeedBlocks}
// FileVersion is one version of a file: its blocklist (SHA-256 of each chunk, in order) and// who wrote it.type FileVersion struct { Rev int64 Blocks []string DeviceID string}
type MetadataStore interface { Latest(ns int64, path string) (FileVersion, bool) // false: no file lives here now // AppendIfLatest is 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 false if someone else committed first. AppendIfLatest(ns int64, path string, expectedRev int64, blocks []string, deviceID string) (int64, bool)}
type Receipts interface { // Has is true once a block server has the bytes and checked their hash. Receipts last a // day, which is well inside the garbage collector's grace period. Has(userID int64, hash string) bool}
type ChangeFeed interface { Publish(ns, rev int64) // a hint for notify servers, not the data itself}
type CommitService struct { Store MetadataStore Receipts Receipts Feed ChangeFeed}
func (s *CommitService) Commit(userID int64, deviceID string, ns int64, path string, parentRev int64, blocks []string) CommitResult { cur, exists := s.Store.Latest(ns, path) if cur.Rev != parentRev { // zero value when nothing exists, so a new file has parent 0 // 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 exists && cur.DeviceID == deviceID && slices.Equal(cur.Blocks, blocks) { return CommitResult{Outcome: Committed, Rev: cur.Rev} } return CommitResult{Outcome: Conflict, Rev: cur.Rev} // never overwrite an unseen edit }
// 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. held := make(map[string]bool, len(cur.Blocks)) for _, h := range cur.Blocks { held[h] = true } var missing []string for _, h := range blocks { if !held[h] && !s.Receipts.Has(userID, h) { missing = append(missing, h) held[h] = true // ask for each block once, even if the file repeats it } } if len(missing) > 0 { return CommitResult{Outcome: NeedBlocks, Missing: 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. rev, ok := s.Store.AppendIfLatest(ns, path, parentRev, blocks, deviceID) if !ok { now, _ := s.Store.Latest(ns, path) return CommitResult{Outcome: Conflict, Rev: 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. s.Feed.Publish(ns, rev) return CommitResult{Outcome: Committed, Rev: rev}}// What a commit can answer. The client acts on each: done, upload these blocks and retry,// or save its edit as a conflicted copy.type CommitResult = | { kind: 'committed'; rev: number } | { kind: 'needBlocks'; missing: string[] } | { kind: 'conflict'; currentRev: number };
// One version of a file: its blocklist (SHA-256 of each chunk, in order) and who wrote it.interface FileVersion { rev: number; blocks: string[]; deviceId: string;}
interface MetadataStore { latest(ns: number, path: string): Promise<FileVersion | null>; // null: no file here 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. Resolves to the new rev (the entry's // journal id), or null if someone else committed first. appendIfLatest( ns: number, path: string, expectedRev: number, blocks: string[], deviceId: string, ): Promise<number | null>;}
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. has(userId: number, hash: string): Promise<boolean>;}
interface ChangeFeed { publish(ns: number, rev: number): void; // a hint for notify servers, not the data itself}
class CommitService { constructor( private readonly store: MetadataStore, private readonly receipts: Receipts, private readonly feed: ChangeFeed, ) {}
async commit( userId: number, deviceId: string, ns: number, path: string, parentRev: number, blocks: string[], ): Promise<CommitResult> { const cur = await this.store.latest(ns, path); const curRev = cur?.rev ?? 0; 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. const same = cur?.blocks.length === blocks.length && cur.blocks.every((h, i) => h === blocks[i]); if (cur && cur.deviceId === deviceId && same) return { kind: 'committed', rev: cur.rev }; return { kind: 'conflict', currentRev: curRev }; // never overwrite an unseen edit }
// 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. const held = new Set(cur?.blocks ?? []); const missing: string[] = []; for (const h of new Set(blocks)) { if (!held.has(h) && !(await this.receipts.has(userId, h))) missing.push(h); } if (missing.length > 0) return { kind: '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. const rev = await this.store.appendIfLatest(ns, path, parentRev, blocks, deviceId); if (rev === null) { const now = await this.store.latest(ns, path); return { kind: 'conflict', currentRev: now?.rev ?? 0 }; } // 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. this.feed.publish(ns, rev); return { kind: '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.
Shard by folder
Section titled “Shard by folder”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.
Stage 3: Shard metadata by namespace
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.
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.
Step 5. Tell devices about changes
Section titled “Step 5. Tell devices about changes”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.
Stage 4: Push changes with long polls
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.
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:
Trace a request: Save a small edit to a 200 MB file
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.
- Devices → Load balancer30 ms
- Load balancer → Metadata servers1 ms
- Metadata servers → Metadata shards3 ms
- Devices → Load balancer160 ms
- Load balancer → Block servers1 ms
- Block servers → Block store40 ms
- Block servers → Block index3 ms
- Devices → Load balancer30 ms
- Load balancer → Metadata servers1 ms
- Metadata servers → Metadata shards6 ms
- Metadata servers → Pub/subasync
- Response
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/commitIdempotency-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 call409 Conflict { "current_rev": 43 } save the edit as a conflicted copyPUT /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.
- 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.
Stress it
Section titled “Stress it”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.
Where to cut a file
Section titled “Where to cut a file”You split files into 4 MB blocks. I insert one byte at the start of a 1 GB file. What uploads?
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.
// 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; }}# 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 one after.class Chunker: MIN = 256 * 1024 # no cut before this: tiny chunks bloat the index 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. MASK = 0xFFFFF000
@staticmethod def gear_table(seed: int) -> list[int]: # 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. table, x = [], seed for _ in range(256): # xorshift32: same numbers in every language x ^= (x << 13) & 0xFFFFFFFF x ^= x >> 17 x ^= (x << 5) & 0xFFFFFFFF table.append(x) return table
GEAR = gear_table(0x9E3779B9)
# The end offset of each chunk. The caller hashes each chunk with SHA-256 to name # the block. Production streams the file; bytes in memory keep the idea visible. @classmethod def boundaries(cls, data: bytes) -> list[int]: ends, start, n = [], 0, len(data) while start < n: end = min(start + cls.MAX, n) cut = end if n - start > cls.MIN: h = 0 for i in range(start + cls.MIN, end): # 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) + cls.GEAR[data[i]]) & 0xFFFFFFFF if h & cls.MASK == 0: cut = i + 1 break ends.append(cut) start = cut return ends// 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.static class Chunker{ const int Min = 256 * 1024; // no cut before this: tiny chunks bloat the index const 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. const uint 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 readonly uint[] Gear = GearTable(0x9E3779B9);
static uint[] GearTable(uint seed) { var t = new uint[256]; uint 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; a span keeps the idea visible. public static List<int> Boundaries(ReadOnlySpan<byte> data) { var ends = new List<int>(); int start = 0; while (start < data.Length) { int end = Math.Min(start + Max, data.Length); int cut = end; if (data.Length - start > Min) { uint 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]]; if ((h & Mask) == 0) { cut = i + 1; break; } } } ends.Add(cut); start = cut; } return ends; }}// 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.const ( minChunk = 256 * 1024 // no cut before this: tiny chunks bloat the index maxChunk = 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 // minChunk, so chunks average about 1.25 MB. Top bits, because they depend on the last // 32 bytes. cutMask uint32 = 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.var gear = gearTable(0x9E3779B9)
func gearTable(seed uint32) [256]uint32 { var t [256]uint32 x := seed for i := range t { // xorshift32: same numbers in every language x ^= x << 13 x ^= x >> 17 x ^= x << 5 t[i] = x } return t}
// Boundaries 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; a slice keeps the idea visible.func Boundaries(data []byte) []int { var ends []int for start := 0; start < len(data); { end := min(start+maxChunk, len(data)) cut := end if len(data)-start > minChunk { var h uint32 for i := start + minChunk; 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]] if h&cutMask == 0 { cut = i + 1 break } } } ends = append(ends, cut) start = cut } return ends}// 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.const MIN = 256 * 1024; // no cut before this: tiny chunks bloat the indexconst 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.const 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.function gearTable(seed: number): Uint32Array { const t = new Uint32Array(256); let x = seed >>> 0; for (let i = 0; i < 256; i++) { // xorshift32: same numbers in every language. >>> 0 keeps each step unsigned. x = (x ^ (x << 13)) >>> 0; x = (x ^ (x >>> 17)) >>> 0; x = (x ^ (x << 5)) >>> 0; t[i] = x; } return t;}const GEAR = gearTable(0x9e3779b9);
// 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; a Uint8Array keeps the idea visible.export function boundaries(data: Uint8Array): number[] { const ends: number[] = []; let start = 0; while (start < data.length) { const end = Math.min(start + MAX, data.length); let cut = end; if (data.length - start > MIN) { let h = 0; for (let 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. >>> 0 wraps to 32 bits. h = ((h << 1) + GEAR[data[i]]) >>> 0; if ((h & MASK) === 0) { cut = i + 1; break; } } } ends.push(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.
Deleting blocks without losing data
Section titled “Deleting blocks without losing data”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.
Conflicts and offline edits
Section titled “Conflicts and offline edits”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.
- 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 folder with 10 million files
Section titled “A folder with 10 million files”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.
Storage that pays for itself
Section titled “Storage that pays for itself”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.
Stage 5: Storage that pays for itself
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.
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.
When things break
Section titled “When things break”The metadata shard for a big shared folder fails over. What do its devices see?
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.
Stage 6: A second region
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.
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:
Break it: what fails, who notices, what users see
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.
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.
Wrap up
Section titled “Wrap up”The whole design
Section titled “The whole design”Here is everything we built, in the order we built it. Use the buttons to step through it.
The full design, stage by 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.
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.
The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| 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 |
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 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.
Follow-up questions
Section titled “Follow-up questions”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.
Further reading
Section titled “Further reading”- Blob storage, partitioning, replication and consistency models on this site; the in-memory file system for the tree itself, and the chat system for long-lived connections and per-device cursors.
- Testing sync at Dropbox, Dropbox engineering (2020): the Nucleus sync engine’s remote, local and synced trees, and why the synced tree is a merge base.
- Inside the Magic Pocket, Dropbox engineering (2016): blocks of up to 4 MB named by SHA-256, a block index, replicas first and erasure coding later, copies in two zones.
- FastCDC: a fast and efficient content-defined chunking approach, USENIX ATC 2016: the gear hash and the normalized chunking that the chunker here simplifies.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.