You edit a spreadsheet on a laptop with the wifi off. Someone else edits the same file
from their desk. You land, open the lid, and the two versions have to become one thing
without anybody being asked to sort it out.
That is the job of a file sync service: one folder that looks the same on every device
you own, even the ones that were asleep or offline when you changed something. Watch one
edit travel from a laptop to a phone.
Split. You save report.xlsx on the laptop. The client splits the file into blocks and names each block by its hash.
Uploading a file is the easy part of this question. Keeping one folder identical across
four devices, two of which are asleep, one on hotel wifi, and one switched off for a month,
is the part that gets designed badly.
A few words come up on every line of this page:
A block (or chunk) is a piece of a file. Files are stored as a list of blocks.
A hash is a short fingerprint of some bytes: same bytes, same hash, every time. If the
idea is new, the hashing article shows one
being computed live.
Metadata is the data about a file rather than the file itself: its path, its size, and
which blocks it is made of.
A namespace is one synced folder: your private Dropbox, or one shared folder. All the
metadata for one namespace is kept together.
The real Dropbox
This page builds a file sync service like Dropbox: edit a file on one device and see it on the others, even after working offline. The numbers and choices below are interview assumptions, not Dropbox's own. Two parts will look familiar if you use Dropbox: files are stored as blocks of up to 4MB named by their hash, and when two edits clash you get a "conflicted copy".
Three of the six answers below decide the whole design, and the first one decides the
bandwidth bill.
You ask
They say
What it settles
How big are files, and how much of one changes in an edit?
A 2KB note up to a 50GB video, and an edit usually touches a few percent.
Chunking. You never upload a whole file after a one line change. Files are split into blocks and only changed blocks move. That one answer removes most of the traffic in the system.
How many devices per user, and are they online?
Three or four, and most of them are asleep most of the time.
The server cannot push data to a device that is not there. So a device pulls a list of changes, starting from a bookmark it keeps. A small nudge wakes it up, and the nudge carries no data.
What happens when two devices edit the same file?
Keep both. Never silently lose one.
Conflicted copies, not a merge. Say this out loud: a general sync service cannot merge two versions of a video, so the honest answer is to keep both and name them clearly.
Do we store identical bytes once, even across different users?
Yes. Storage is the second largest line on the bill.
Blocks are named by their hash, so identical blocks are stored once. That has a privacy cost you should name: if an upload finishes instantly because someone else already has that block, you have told the uploader that the file exists on the service.
How fast must a change reach another device?
A few seconds when both are online.
A long lived notification channel, separate from the data. The nudge is tiny and urgent, the transfer is large and allowed to be slow. Mixing them is how one slow upload delays everybody.
Do we need version history?
30 days, plus undelete.
Almost nothing extra, which is the quiet payoff of chunking. Blocks are never changed after they are written, so an old version is just an old list of hashes, and a delete is a flag.
Three inputs. Watch which number ends up in the headline, because it is not the one most
people expect. (The back of the envelope chapter explains
the method.)
What actually moves, and what actually piles up
500 million
500
12 MB
Files stored500M x 500 = 250 billion
Logical bytes250B x 12MB = 3.0 EB
After storing shared blocks once, about 25% less2.3 EB
Metadata rows at 3 versions each250B x 3 = 750 billion
Metadata size at ~300 bytes a row225 TB
750 billion rows
of metadata, which is a harder problem than the exabytes of file content
Two things fall out of this. The bytes go to object storage, which you can buy by the petabyte. The 750 billion small rows, read in order by every device from its own bookmark, are the part that gets redesigned twice. And chunking is the whole bandwidth story: a one line change to a 12MB document moves one 4MB block, and a one line change to a 2GB video also moves one 4MB block.
The number people get wrong
Candidates size the block store, find an impressive number of petabytes, and design around
it. Blocks are the easy half: they never change once written, they are named by their
content, and you can buy that storage. The metadata is the half that hurts. Every device
keeps asking "what changed since I last asked", and that question has to be answered in
order, cheaply, billions of times a day, for folders that range from one file to two
million.
Five calls, and the first one is the one most designs are missing.
POST/v1/blocks:check
{
"hashes": ["a3f1...", "7c90...", "1b2e..."]
}
returns 200 { "missing": ["7c90..."] }
Why: The client asks before it uploads. On an office network where the same 2GB installer sits in forty people's folders, the thirty ninth person uploads nothing at all. This one call is the whole storage saving, and it costs a hash the client was computing anyway.
PUT/v1/blocks/{sha256}
returns 201
Why: The name of the block is the hash of its bytes, so uploading the same block twice does nothing extra. That makes every retry safe (the word for this is idempotent, explained in the idempotency keys article), and a transfer that got corrupted fails its own hash check instead of quietly storing garbage.
Why: Metadata is committed last, after every block is safely stored, so a file never appears pointing at bytes that are not there yet. baseVersion says "I was editing version 41207". If the server is already past that, someone else got in first, and the 409 becomes a conflicted copy.
Why: A device asks "what happened after 41207?" and gets an ordered list. A laptop that was off for a month asks once with an old number and gets everything since, in order. No special catch up code. This is the most important endpoint in the system.
GET/v1/longpoll?cursor=41207
returns 200 { "changed": true } after up to 90 seconds
Why: A cheap request the server holds open until something happens, then answers "come and ask". Keeping this nudge separate from the data keeps the notification servers tiny and easy to restart, which matters when every awake device on the planet holds one open.
That last call is a long poll: the device sends a request and the server simply does not
answer until it has news (or about 90 seconds pass, and the device asks again). It is one of
four ways to get news from a server to a client, compared side by side in
WebSockets vs SSE vs long polling.
The block upload gets a property for free that most APIs work hard for. Because the block's
name is its own hash, sending it twice changes nothing, so a phone that loses signal halfway
can simply send it again. A request that is safe to repeat is called idempotent, and
idempotency keys shows the extra work other APIs need to get
the same guarantee.
Two stores, split by how the data behaves rather than by what it is. Blocks are big and never
change. Metadata is small and changes all the time.
namespace_journalsharded by namespace, append only, cursor ordered
namespace_id
bigint
PK
Which folder this row belongs to: one user's private folder or one shared folder. All rows for a folder live together, so reading them in order is one quick scan.
seq
bigint
PK
A counter that only goes up within the folder. This number is the cursor every device remembers.
path
varchar(1024)
IDX
The path at this moment. A rename is a new row, not an update, which is why history survives one.
block_list
array of sha256
The file is this list. The bytes live somewhere else entirely, shared with whoever else has the same blocks.
size, mtime
bigint, timestamp
Enough for a device to decide what to fetch without opening anything.
deleted
boolean
A delete is a new row like every other change. That is why undelete and 30 day history come for free.
Nothing in this table is ever changed in place. That is what append only means: new rows go on the end, old rows stay. A device syncs by reading forward from its cursor and applying what it finds. So two devices can never disagree about the truth: the journal is the truth, and the cursor says how much of it you have seen.
blocksobject storage, keyed by content hash
sha256
char(64)
PK
The name is the content. Two users with the same holiday photo store it once.
size
int
Up to 4MB. Bigger blocks mean fewer metadata rows but more bytes moved per small edit. This number is the knob.
refs
approximate
Not an exact count of who uses the block. Counting that exactly across a billion folders is a distributed counter nobody wants.
Sample row
a3f19c... | 4194304 | many
Deleting a block the moment nothing points at it means keeping that exact count, and getting it wrong deletes somebody's data. So nothing is deleted straight away. A background job (mark and sweep: first mark every block some file still uses, then sweep away the rest) runs offline, and blocks are kept longer than the longest version history. Storage is cheaper than the incident.
The blocks live in object storage: a huge, cheap store for files addressed by a key. How
one works inside is its own question, in design object storage.
Figure 1 is the same trip as the picture at the top, with the background jobs added.
split1which are missing?2upload the rest3commit45append6something changed7wake the others8come and ask9membershipold versionsunreferencedDesktop clientwatches the folderChunkercontent definedSync APIstatelessNotify channellong pollBlock checkhave you got these?Metadata servicecommits lastChange notifierwakes devicesBlock storeobject storageJournal DBsharded by namespaceSharing servicewho is in a namespaceGC sweepermark and sweepVersion history30 days
Figure 1. Two paths that meet only at the commit. Blocks go up the top of the picture and are allowed to be slow. The journal entry goes across the middle and is what makes the change real for every other device.
Walking Figure 1:
The client notices a file changed and splits it into blocks. This happens on the device,
before anything touches the network. How it splits is the first deep dive.
It asks which of those block hashes the server is missing. Usually most are already
there, from the previous version of this file or from another user entirely.
Only the missing blocks are uploaded, straight to the block store. Uploads can resume
after a dropped connection and run in parallel.
Then, and only then, the client commits: a path, an ordered list of hashes, and the
version it believed it was editing.
The metadata service checks that version against the journal.
If it matches, one row is appended. The file is now real, and it was never real at any
moment when its bytes were missing.
The notifier learns that a namespace moved.
Every awake device holding a long poll for that namespace is told, with no payload.
Each of them asks for changes since its own cursor and gets exactly what it missed.
The three dashed arrows are background work. Membership decides who sees a namespace,
history is a view over old journal rows, and a sweeper eventually deletes blocks nothing
points at any more.
Your answer
Step 2 asks the server which blocks it already has before uploading anything. What does that one question buy you, and what does it quietly leak?
You changed one line of a 2GB file. What goes over the network?
1
First try
Upload the whole file on every save
Watch the folder, and whenever a file changes, upload all of it. It works on day one and it is how most people first sketch this.
Where it breaks: A one line change to a 2GB video project moves 2GB. Every save. From every device. Over hotel wifi.
2
Better
Fixed size blocks, upload only the changed ones
Cut the file every 4MB, hash each block, and upload only blocks whose hash is new. Change a byte in the middle and one block moves.
Where it breaks: Insert a line at the top of a document. Every byte after it shifts along, so every 4MB cut now lands somewhere different, every block looks new, and a one word edit uploads the whole file again.
3
What you ship
Content defined chunks, and ask before uploading
Let the bytes themselves decide where the cuts go, so an insert disturbs only the block it lands in. Then ask the server which block hashes it is missing and send only those.
The middle rung is the one to feel. Try both ways of cutting on one sentence, then add a word
at the start.
Add one word, count the blocks
Meeting moved to Friday. Bring the sales numbers and the new budget sheet for Q3.
Blocks to upload: 0 of 7
This is the file before the edit. Each box is one block, named by its hash. Now add a word at the start.
Toy sizes so you can see every cut: a 2 letter window and blocks of a few letters. A real client slides a 48 byte window over the file and aims for blocks of a few megabytes.
Content defined chunking works like this. A small window slides along the file one byte
at a time, and the client keeps a hash of just the bytes inside the window. Whenever that
hash happens to match a pattern, it cuts. Because the decision depends only on the bytes in
the window, not on how far into the file you are, the cuts after an insert land in exactly
the same places as before. Here is the same thing one stage at a time.
1/4 A small window slides over the file one byte at a time. A rolling hash is cheap to update as the window moves: one add and one subtract per byte, not a fresh hash. That is what makes this affordable on a 50GB video.
Figure 2. Boundaries are chosen by what the bytes are, not by how far through the file you are. Insert something at the top and exactly one chunk changes, because the boundaries after the insertion land in the same places they did before.
Be honest about the cost. With variable sized chunks you cannot jump to byte 9,000,000
without walking the chunk list, and the rolling hash is real CPU on the client. Both are
fine. The alternative is uploading a 2GB video because somebody renamed a layer in it.
The follow up you will get
"What chunk size would you pick?" Smaller chunks share better and move less per edit, but
cost more metadata rows and more round trips per file. Give the shape of the trade and a
range, not one number: somewhere around 1MB to 8MB on average, tuned by measuring bytes
moved against bytes changed on real files. Then add the detail that shows you have thought
about it: you also need a minimum and a maximum chunk size, because an odd file can produce
a boundary every 50 bytes, or none at all for a gigabyte.
Your phone was asleep. How does it find out what changed?
1
First try
Compare the whole folder with the server
The phone lists every file it has, the server lists every file it has, and they fix the differences. It is correct.
Where it breaks: It costs a full comparison on every sync, the phone must hold the whole tree, and it gets slower as the folder grows even when nothing changed. A folder with two million files pays for two million files to learn that nothing happened.
2
Better
The server pushes each change to every device
When the laptop commits, the server sends the change itself to the phone and the desktop.
Where it breaks: A phone that is asleep misses the push. Now the server has to remember what every device has and has not received, and a phone that missed twenty changes needs a separate catch up path that is rarely run and therefore buggy.
3
What you ship
A journal, a cursor per device, and a nudge with no data
Every change is one row in an ordered journal. Each device remembers one number, its cursor: the last row it has applied. Syncing means asking "what came after my number?". A long poll only says "something changed, come and ask".
Think of a group chat you muted for a week. You do not ask your friends to resend
everything, and you do not compare your phone against theirs. You scroll from the last
message you read. The cursor is that "last read" line. (This is the pull side of
push vs pull: the server pushes a tiny nudge, the device pulls the
data when it is ready.)
LaptopSync serviceJournalPhonelong poll, cursor 41207commit, base 41207append, now 41208changed: trueGET /changes?cursor=41207fetch 1 missing block
1/6 The phone is awake and holds a connection open that costs almost nothing. It is not asking for data. It is asking to be told when there is some.
Figure 3. A laptop that was closed for a month and a phone that was awake the whole time run exactly the same code path. One of them just has a smaller number.
The property worth naming out loud: there is no catch up path. Offline for an hour and
offline for a year run the same code with a different number in it. The rarely used path and
the constantly used path are the same path, which is how you avoid the bug that only shows
up after somebody's long holiday.
The journal is split across machines by namespace (the sharding
chapter explains splitting data this way). One folder never spans two machines, so its rows
stay in one order and the cursor stays one simple number. The price of that choice shows up
in Break it below.
The commit sends baseVersion: 41207, which means "I was editing version 41207". If the
journal is already at 41209, someone else committed in between, and the server answers 409.
Nothing was locked while you edited. The server only checks at the end whether the version
you started from is still the latest. That pattern is optimistic locking, and
optimistic vs pessimistic locking explains
when it is the right bet. For files edited on laptops that are often offline, it is the only
bet: you cannot hold a lock on a laptop in a plane.
The 409 is where a product decision lives, and the interviewer is asking which one you make.
Try all three.
The second device writes its version to a new path, "report (conflicted copy from Sam's laptop).xlsx", and both appear on every device. It looks unsophisticated and it is the right answer: no data is lost, the user can see exactly what happened, and a human sorts it out with context the server does not have.
Pick conflicted copies, and say the sentence that justifies it: the server knows the bytes,
the user knows the intent, and only one of those is qualified to throw work away. (When the
product really does need two people in one file at once, it becomes
Design Google Docs, a different design.)
Push each failure and watch which box goes red first. Notice that none of them is about
bytes.
Sync pressure
normal
normalshared team foldersomeone pastes 200k filesa client goes into a looprepaired
append and readbytesDevicesSync APIJournal DBby namespaceBlock store
append and readbytesDevicesSync APIJournal DBby namespaceBlock store
Healthy. Most devices are asleep. The awake ones hold a long poll that does nothing, commits are a few rows, and the block store handles large but entirely unexciting transfers.
Read every step as text
normal · Healthy. Most devices are asleep. The awake ones hold a long poll that does nothing, commits are a few rows, and the block store handles large but entirely unexciting transfers.
shared team folder · Degraded. Fifty people share one folder, so one commit wakes fifty devices and all of them ask for changes in the same 200 milliseconds. One commit became fifty reads of one partition, instantly. Add a small random delay to each wake up (jitter), and return small changes inside the long poll reply so a tiny edit does not cost a second request from every device.
someone pastes 200k files · Down. A namespace lives on one partition by design, so 200,000 appends all land on one machine while the rest of the fleet idles. That is a hot partition, and this design accepts it on purpose. The fixes are a write rate limit per namespace and batching a bulk import into fewer, larger journal rows.
a client goes into a loop · Down. A bug makes one device re-upload the same folder every thirty seconds. The block check makes it cheap in bytes and expensive in requests, which is the failure you never see on a bandwidth graph. Rate limit each device, and put a "back off for N seconds" value in every response, so you can slow a misbehaving client without shipping it a new build.
repaired · Healthy. None of the repairs were about bytes. Every failure in this system is about metadata operations and wake ups, which is why the napkin math put the row count in the headline and not the petabytes.
Rate limits per namespace and per device are the main tool in two of those levels. The
difference between refusing work and slowing it down is in
rate limiting vs throttling vs backpressure.
Someone edits a file on their laptop. Take me through what reaches the server.
Opening. They want to know whether you upload the file.
You
Not the file. The client chunks it with a rolling hash, so boundaries are chosen by content, hashes each chunk, and asks the server which of those hashes are missing. Usually almost none are, because most of the file did not change. It uploads the missing chunks to the block store, and only after they are stored does it commit metadata: the path, the ordered list of hashes, and the version it thought it was editing.
Blocks before metadata, stated as an order with a reason. That order is the whole correctness story.
Interviewer
Why not split the file every four megabytes?
Checking whether content defined chunking was a decision or a phrase.
You
Because an insert at the start shifts every byte, so every fixed boundary lands somewhere new and the whole file looks changed. With a rolling hash the boundaries are decided by the bytes around them, so after the inserted part the window sees the same content it saw before and the cuts line up again. One chunk changes instead of three thousand.
Explains why the cuts line up again, which is the part people repeat without understanding.
Interviewer
How does another device find out?
The sync model question.
You
It holds a long poll that returns a bare "something changed", then asks for changes since its own cursor. I keep the data out of the notification on purpose: once the push carries data, it has to know what each device has already seen, and a device that slept through twenty changes needs a different code path. With a cursor, a device offline for an hour and one offline for a year do exactly the same thing.
Refusing to put data in the push, and explaining it as fewer code paths rather than as purity, is the senior framing.
Interviewer
Two devices edit the same file while both are offline. Then both come back.
The conflict question. There is a right answer and it is not clever.
You
The first commit wins. The second gets a 409 because its base version is out of date, and the client writes its version as a conflicted copy with the device name in the filename. I would not try to merge. A sync service does not know whether it is holding a text file or a video project, and a bad automatic merge destroys work in a way that is much harder to recover from than two files sitting side by side.
Picks the unglamorous answer and defends it on how much damage the alternative can do, not on difficulty.
Interviewer
Fifty people share a folder and someone drops two hundred thousand files into it.
The hot partition question, dressed up as file sync.
You
The namespace is one partition on purpose, so that is 200,000 appends to one shard, and then fifty devices waking up to read it. I would rate limit writes per namespace, batch a bulk import into fewer journal rows instead of one per file, and jitter the wake ups so fifty clients do not arrive in the same 200 milliseconds. What I would not do is split the namespace across shards, because the ordered cursor is the thing that keeps every client simple.
Names the fixes, then names what they refuse to give up, which is the harder half of the answer.
Interviewer
How long would you keep deleted blocks before collecting them?
Open ended. Checking for an invented number.
You
Longer than the version history window, and I would want to measure rather than assert. The risk is lopsided: collecting too late costs storage, which is cheap, and collecting too early loses data, which cannot be undone. So I would start at something like twice the retention window, run the sweeper in a dry run mode that only reports what it would delete, and compare that against expectations for a while before letting it delete anything.
The dry run is what someone who has written a destructive background job says. It costs one sentence and it is very hard to fake.
1. Why are chunk boundaries chosen by a rolling hash rather than by byte offset?
2. Why must blocks be uploaded before the metadata commit, not after?
3. Same design, but it now syncs a single multi gigabyte database file that changes constantly. What breaks first?
Worth memorising
4MB blocks: a one line edit to a 2GB file moves 4MB. That is the whole bandwidth argument.
Hundreds of billions of metadata rows, which is a harder problem than the exabytes of content.
One cursor per device per namespace. Offline for an hour and offline for a year are the same code path.
Blocks first, metadata last. A file must never exist in the namespace pointing at bytes that are not there.
Say this in 60 seconds
A file sync service is a chunking problem and a cursor problem, and almost nothing else. The client splits each file using a rolling hash so boundaries are decided by content, which means inserting a line changes one chunk instead of every chunk after it. It asks the server which chunk hashes are missing, uploads only those, and commits metadata last, so a file never exists in the namespace pointing at bytes that are not there. Metadata is an append only journal per namespace, and every device remembers one cursor into it, so syncing is asking what came after that number. A device offline for an hour and one offline for a year run exactly the same path. Notifications are a separate long poll that carries no data, just a nudge, which keeps the push tier stateless. Concurrent edits produce a conflicted copy rather than a merge, because a sync service does not know the file format and losing work silently is much worse than showing two files. The failure I would call out is that a namespace is deliberately one partition, so a shared folder with a bulk import is a hot shard, and I would rate limit and batch rather than give up the ordered cursor.