File sync across devices — a full worked answer
The whole thing, start to finish, in the order you would actually say it. Read it to learn the shape of a strong answer — then go and produce one yourself, because reading is not the same skill as producing.
The question
Design a file sync service. A folder on several devices stays in step, edits made offline sync when the device reconnects, and history is recoverable. Files range from a few kilobytes to several gigabytes.
Sync and offline. The same data lives on several devices, all of which can be edited while disconnected.
Two people (or one person on two devices) changed the same thing offline. Everything hinges on whether you detect that or silently pick a winner, and on how little you can transfer to bring a device up to date.
Before anything: where is the slack?
How long the system has before a person notices. Ask this before you draw a single box — it decides more of the design than scale does.
The local device must feel instant even with no network at all. Everything else (reaching other devices, agreeing with the server, resolving conflicts) has real slack. The design is built on that asymmetry.
A user saving a file has zero tolerance. The save writes to local disk and returns, even offline. Sync happens afterwards, in the background. If the product ever makes someone wait for the network to save a file, it has failed at its core job.
Propagation has seconds of slack. If a change reaches another device in three seconds rather than three hundred milliseconds, the user will not notice, because they are rarely watching two devices at once. So propagation can go through a queue and be batched, which matters a lot when someone drops in a folder of 10,000 files.
A new device syncing 200 GB has hours of slack, and the user knows it. What it must not do is block anything else, or start from the beginning if it is interrupted at 90%.
The place with no slack and no way to buy any: not losing an edit. If two devices both edited a file offline and one version silently disappears, that is not a sync delay. It is data loss, and the user will not know it happened.
So: The client is the source of truth for local state and works fully offline. Sync is asynchronous, resumable and chunked. Conflicts are detected and preserved as both versions rather than resolved by picking a timestamp.
Requirements
about 5 minWhy this stage exists: Because the question is deliberately vague, and every box you draw before this is a guess.
Assumptions: desktop and mobile clients, folders can be shared between users, files from a few kilobytes to several gigabytes, and the client works fully offline with sync resuming on reconnect.
The question that changes a box: do we need to merge concurrent edits to the same file, or is preserving both acceptable? Real merging means understanding file formats. You can merge text; you cannot merge a video. So a general file sync product cannot merge. I will assume we detect the conflict and keep both versions, naming one "Alice's conflicted copy". It sounds like a cop-out, but it is the correct engineering answer, because the alternative is silently discarding somebody's work.
Second question: is deduplication across users acceptable? Storing one copy of a file that ten thousand users all have saves a lot of storage, but it leaks information: an attacker can learn whether a file already exists somewhere by watching whether its upload completes instantly. I will assume deduplication within a user's own account only, which is where most of the benefit is without the disclosure.
Scope I propose: syncing a folder across a user's devices, sharing with other users, offline edits with conflict detection, resumable upload and download of large files, and version history. Out of scope: real-time collaborative editing inside a document, which is a different system built on operational transforms or CRDTs rather than on file sync.
What an interviewer is ticking off in this stage
- Established sharing between users, not just one user's devices
- Asked about merging and chose conflict preservation, with the reason
- Asked about cross-user deduplication and named the information leak
- Excluded real-time collaborative editing as a different system
- Committed to full offline operation as a requirement
Actors and lifecycle
about 6 minWhy this stage exists: Because the happy path is the easy half. The failure branches are where the design actually lives.
Actors: the user, each of their devices as a separate actor with its own local state, other users sharing the folder, and the system itself, which decides what counts as a change, which version is current, when two versions are in conflict, and what history to retain.
A file's life: created or modified locally → detected by the client watcher → hashed and split into chunks → chunks not already on the server are uploaded → a new version is committed on the server → other devices are notified → they download the missing chunks → they apply the change locally. Plus deletion, which is a version like any other, and restore from history.
Failure branches. There are many, because this system runs on devices and networks it does not control. At detect: the file is still being written (someone is saving a 2 GB video), so uploading now captures a corrupt half-file. The client has to wait for the file to settle rather than react to the first event. At upload: the connection drops at 90% of 5 GB, which must resume from the last completed chunk, not restart. At commit: two devices commit versions derived from the same parent, which is the conflict case and the heart of the product. At notify: a device is offline, so it must find out on reconnect by asking what changed since the last version it saw, rather than depending on a push it missed.
The branches the system owns. A device offline for a month reconnects: sending it every individual change since then could be a hundred thousand events, so it needs a compacted answer: the current state of everything that changed, not the full history of how it got there. A user drops in a folder of 50,000 small files: that must not become 50,000 separate round trips, so the client batches. And the local disk fills mid-sync, which has to fail gracefully and resumably rather than leaving a half-written file that looks complete.
What an interviewer is ticking off in this stage
- Each device modelled as its own actor with its own state
- Waiting for a file to settle before uploading a partially written file
- Resumable upload from the last completed chunk
- Two versions from the same parent named as the conflict case
- Reconnect is a pull of what changed since a version, not a replayed push
- A long-absent device gets compacted current state, not full history
The lifecycle, drawn
Numbers
about 6 minWhy this stage exists: Because one part of this is hard and the rest is easy, and they look identical until you do the arithmetic.
Assume 50 million users, 50 GB each on average, so 2.5 exabytes of raw data. It goes straight to object storage. The one decision it forces is a retention and tiering policy, because keeping every version of everything on hot storage is not affordable.
Chunking is where the arithmetic becomes interesting. Split files into fixed 4 MB chunks and store each by content hash. A user edits one paragraph in a 2 GB presentation: without chunking that is a 2 GB upload, with chunking it is one 4 MB chunk, because every other chunk hashes the same and is already on the server. That is a 500-fold reduction for a common case, and an interrupted upload can resume at chunk granularity. No other decision on the board does as much for the product.
Metadata is the part people underestimate. 50 million users times, say, 20,000 files each is a trillion file records. That does not fit on one machine and it is not object storage's job. It needs a partitioned database, and the partition key has to be the user or the account, so that "list my folder" is one partition and never a scatter across the cluster.
Change notifications: if 10 million devices are online and each sees a few changes an hour, that is roughly 10,000 notifications a second, which is small. The cost is holding 10 million open connections, not the messages. It is the same shape as chat.
So the hard part here is conflict handling and metadata at a trillion rows, not bandwidth. Chunking and object storage make the bytes a solved problem. The design is about knowing which version is current for every file on every device, and detecting when two devices disagree.
What an interviewer is ticking off in this stage
- Separated bulk data (object storage) from metadata (partitioned database)
- Quantified the chunking saving with a concrete example
- Estimated the metadata row count and said it must be partitioned by user
- Noted connections rather than message volume as the notification cost
- Finished the sentence: conflicts and metadata, not bandwidth
The numbers, drawn
So the hard part is conflicts and metadata, not bandwidth. Chunking plus object storage makes the bytes a solved problem; knowing which version is current on every device is the design.
High-level design
about 12 minWhy this stage exists: Because now — and only now — you have a reason for every box you are about to draw.
The client is a full part of this system, not a thin caller. It holds a local database of every file, its version, its chunk hashes, and its sync state. That is what makes offline work possible, and it is why the client can answer "what changed" without asking anybody. A watcher observes the filesystem, waits for files to settle, hashes them, and compares against its local record.
The metadata service owns the truth about versions, partitioned by account. Each file has a monotonically increasing version, and each account has a cursor: a single number that advances with every change in that account. A device syncs by saying "I am at cursor 8,412, what has changed since?" and getting back a compact list. The cursor is the most useful element in the design. Reconnecting after a month costs about the same as reconnecting after a minute, because the server answers with current state rather than replayed history.
Chunk storage is object storage, keyed by content hash. The client asks "which of these 50 hashes do you already have", uploads only the missing ones over presigned URLs directly to storage, and then commits a new file version referencing the full list. The bytes never touch my servers. At exabyte scale that is a requirement, not an optimisation.
Commit is where correctness lives. A new version is accepted only if its parent version matches what the server currently has: a conditional update, like the seat reservation. If the parent does not match, another device got there first, and this is a conflict rather than an overwrite. That one check is what prevents silent loss, and it takes about two lines of code.
Notification is a persistent connection per device, carrying only "the cursor moved" rather than the change itself. The device then pulls the delta over normal HTTP. That keeps the socket layer trivial and makes a missed notification harmless, since the next connection reveals the gap anyway.
Sharing adds a folder-level access record, and a change in a shared folder advances the cursor for every member. Permission checks happen at the metadata service on every access, not at the edge, because a chunk hash is effectively a capability and must never be servable without an ownership check.
What an interviewer is ticking off in this stage
- Client holds a local database and can operate entirely offline
- Per-account cursor so syncing is a delta, not a replay
- Chunks in object storage by content hash, uploaded over presigned URLs
- "Which of these hashes do you have" before uploading anything
- Commit is conditional on the parent version — this is what prevents loss
- Notifications carry only that the cursor moved; the client pulls the delta
- Permission checked at metadata access, never inferred from knowing a hash
The architecture, drawn
Deep dive and tradeoffs
about 14 minWhy this stage exists: Because naming what a decision costs is the single clearest signal of seniority there is.
Hard part one: the offline conflict. Two devices both had version 7 and both edited while disconnected. Device A reconnects and commits version 8 successfully, because its parent matched. Device B reconnects and tries to commit with parent 7, but the server is now at 8, so the conditional commit fails. That failure is the correct outcome. B has not lost anything; it has been told it is behind. B then downloads version 8, and because it cannot merge arbitrary files, it writes its own edit as a new file named for the conflict and syncs that as a separate object. Both versions now exist and both are visible. The costs: the user sees a file they did not create and has to deal with it, and a folder synced across many devices after a long offline period can produce several conflicted copies, which feels messy. It is still the right trade. The alternative is choosing a winner by timestamp, and consumer device clocks are often wrong, so that choice would delete real work with no error and no way to notice.
Hard part two: 50,000 files at once. A naive client does 50,000 hash checks, 50,000 uploads and 50,000 commits, and the round trips alone take hours while the metadata service sees a burst of writes for one account: a self-inflicted hot partition. So: the client batches. Hash existence is asked in batches of hundreds. Uploads run with bounded parallelism, a few at a time, so one user cannot saturate their own connection or my ingest. Commits are batched into a single call that advances the cursor once for the whole batch rather than 50,000 times, which is what stops every other device receiving 50,000 notifications. Cost: the batch commit is more complex, and a partial failure has to be resumable at file granularity within the batch.
Chunk garbage collection. Chunks are shared between versions, between files, and in shared folders between users. Deleting a file cannot delete its chunks, because another version or another file may reference them. So chunks are reference counted, or swept periodically by finding unreferenced hashes. A sweep must be conservative and ignore anything recently created, because a chunk uploaded seconds ago may not yet have a version referencing it. Deleting a chunk that is still needed is unrecoverable data loss, so the job errs toward keeping too much. Cost: real money spent on storage that nothing references, and a job nobody should make more aggressive without careful review.
History and restore. Versions are cheap because they share chunks, so keeping 30 days of history costs far less than 30 copies. Beyond that, thin them out. Restore is just committing an old version as a new one, which keeps history append-only and means restore is itself undoable.
What an interviewer is ticking off in this stage
- Walked the conflict through both devices, not just the server
- Explained why timestamps cannot decide it: consumer clocks are wrong
- Batched hash checks, uploads and commits for a bulk import
- One cursor advance per batch, so other devices are not flooded
- Chunk garbage collection by reference, conservative on purpose
- History cheap because versions share chunks; restore is a new version
The decision this turns on
- —One version survives, so the folder stays tidy and the user is never confused.
- —Depends on device clocks, which on consumer machines are routinely wrong.
- —The loser is deleted with no error, no notification and no way to recover it.
- —Feels clean right up until someone loses an afternoon of work.
- —The conditional commit fails, so the second device knows it is behind.
- —Both versions exist; nothing is ever silently discarded.
- —The user sees a conflicted copy they did not create and must resolve it.
- —A long offline period across several devices can produce several such copies.
And then they push back
A complete answer is where the interview starts, not where it ends. These are the questions this design invites — the interview is decided here far more often than in the diagram.
Two users click at the same millisecond. What happens?
"We would use a lock." Which lock, held where, and what happens if the holder pauses? A lock is a mechanism, and reaching for it before asking whether the resource is contested at all is a reflex rather than an answer.
"First I would ask whether the thing is contested. If it is a like button, both writes land, the count converges, and I would choose not to spend a transaction on it. If it is the last seat, exactly one must win, and I get that by making the decision at a single point of serialisation: a conditional update that only succeeds if the seat is still free, or a unique constraint so the second insert simply fails. I am not proposing to read and then write in application code, because that gap is the race itself. The loser gets a clear message and an alternative, not an error. Cost: that path is now strongly consistent, so it is slower and it becomes unavailable if the owning partition is unreachable, which I accept, because selling one seat twice is worse than an error message. I would specifically not add a distributed lock, because the database row is already a single point of serialisation and a lock adds a failure mode where a paused holder's lease expires and two processes both think they won."
Now it must work offline and sync when the connection returns.
"We would cache data locally and sync later." That is the easy direction. The hard direction is writes made offline that conflict with writes made elsewhere, and sync order.
"Reads offline are straightforward: cache locally with a clear indication of how stale it is. Writes are the design. Every offline change becomes a queued operation with a client-generated id, applied optimistically to local state so the app feels responsive, and replayed on reconnection. Then the two hard problems. Conflicts: the same record was edited on the device and on the server, so I need a resolution rule — last-write-wins is simple and silently discards someone's work, per-field merging is better where fields are independent, and for heavily collaborative data a merge-friendly data type is the right tool. Ordering: operations must replay in the order they were made, and one failing must not silently skip the rest. Also, an operation queued offline may no longer be valid (the item was deleted, the permission revoked), so the server must be able to reject it and the client must be able to show that rejection to the user rather than losing it silently. What I would flag: this is a much bigger change than it sounds, because it turns every write path into a distributed multi-leader system."
A worker dies halfway through processing a batch. What is the state of the world?
"The job would be retried." Retried from where? If the first half already had side effects, retrying repeats them, and now you have sent 5,000 emails twice.
"It depends on whether I made the work idempotent, which is a decision I have to have made beforehand. The message was not acknowledged, so it is redelivered. That is at-least-once delivery, and it is the only guarantee I can get. So a retry reprocesses the first half. If each item's effect is idempotent — an upsert by a natural key, setting a status rather than incrementing — the retry is harmless and this is a non-event. If the effects are external, like sending an email or charging a card, each one needs its own idempotency key carried to the provider, and I need to record progress per item rather than per batch so a retry skips what is done. The design lesson is to record completion in the same transaction as the work where possible, so there is no window where the work happened and the record of it did not."
Your storage bill doubles every six months. What do you do?
"We would delete old data." Which data, and who agreed you could? Retention is usually a product and legal decision, not an engineering one.
"First, find out what is actually growing, because it is rarely uniform — usually one dataset, often logs or raw events or derived copies nobody remembers creating. Then, in order of cheapness: lifecycle policies moving cold data to colder tiers, which is cheap to implement and can cut the bill sharply when most data is rarely read after its first week; compression and columnar formats, which for event data is a 5-10x reduction; tiered granularity, keeping recent data fine-grained and rolling older data up; and only then deletion, which needs a retention policy agreed with the product and legal side rather than decided by me. I would also check for duplication — a derived index, a backup of a backup, an old migration copy — because a surprising share of storage growth is copies. The one thing I would not do is delete raw data that lets me reprocess when a job turns out to be wrong, because that is the safety net that makes everything else correctable."
You grow 10x every year for three years. What do you rebuild?
"The design already scales to any size." No design does, and claiming so means you have not identified where your assumptions run out.
"Year one, nothing. I sized the current design with headroom and it absorbs 10x. Year two, the single-primary database stops being enough and I have to partition, which is the expensive one: it means choosing a shard key, dual-writing, backfilling and cutting over, and it takes a quarter. That is why I chose the data model on day one so the natural shard key was already the primary access key. The migration is painful, but it is not a redesign. Year three, the thing I expect to hurt is not capacity but blast radius and operations: one global system serving 1,000x the original traffic needs regional isolation so one failure does not take everything down. So I rebuild the storage layer once, and I plan for it by picking the key correctly now."
You write to the database and then publish an event. Name the two failure modes.
"We would use a transaction." A database transaction does not cover the message broker, and that is the problem. This answer suggests not seeing that the two systems have no shared transaction.
"One: the row commits and the process dies before publishing, so downstream never learns about it. That is a lost event, and it is the worst kind, because nothing looks broken and no error is raised. Two: I publish first and the transaction rolls back, so consumers act on something that does not exist: a phantom event. At enough volume both will happen, because no transaction spans both systems. The fix is the outbox pattern: write the event into a table in the same transaction as the data, so they are atomic by construction, and let a separate relay publish from that table. The remaining cost is that the relay can publish twice if it dies between publishing and marking sent, so consumers must be idempotent. With at-least-once delivery they had to be anyway. Change data capture off the database log gets the same guarantee with less application code, and I would use that when the consumer is derived state like a search index."
Now it must be privacy compliant — users can demand deletion of everything about them.
"We would delete their row from the users table." Their data is in caches, replicas, backups, logs, analytics, search indexes and immutable event archives. One row is the easy part.
"The hard part is that data has been copied everywhere by design. I would inventory it: primary store, replicas, caches, search indexes, the raw event archive, backups, logs, and any third party I forwarded it to. Deleting from the primary is trivial; the archive is the hard one, because immutable columnar files cannot have a row removed cheaply. Two practical approaches: crypto-shredding, where each user's identifiers are encrypted with a per-user key and deleting the key makes the data unreadable, which is what makes this tractable at scale; or periodic compaction that rewrites partitions with deleted users removed, which is expensive but complete. For backups, deletion usually cannot be immediate, so the policy is that backups expire within a stated window and deletion is applied on restore. What I would change on day one knowing this: keep identifying data in as few places as possible, reference it by id everywhere else, and never copy raw identifiers into derived stores. That one rule turns this from an archaeology project into a manageable one."
You need to move this data to a new store with no downtime. How?
"We would migrate over a weekend." That is downtime, and at any real data volume a weekend is not long enough anyway.
"Dual-write and backfill, in phases. Phase one: start writing to both old and new stores, with the old one still authoritative. This is the risky part, because a write that succeeds in one and fails in the other creates drift, so I make the new-store write non-blocking and reconcilable rather than letting it fail user requests. Phase two: backfill history into the new store, in batches, throttled so it does not affect production. Phase three: verify, by comparing reads from both for a sample of traffic and measuring the disagreement rate. This step is easy to skip, and it is the one that tells you whether the migration is correct. Phase four: switch reads over gradually, a percentage at a time, with a fast way back. Phase five: stop writing to the old store, and only then, after a safe period, delete it. Cost: weeks of running both, double writes, and a comparison harness. That is why the shard key and data model choices made on day one matter so much."
The topics this answer used
You have read a good answer. That is worth very little until you can produce one on a blank page — so go and write this problem without looking, and compare afterwards.