Blueprint
Media & files advanced

Design Dropbox

A 50 GB file over a flaky connection, edits that must sync in seconds, and a data plane that must never route through your app servers.

asked at Dropbox · Google · Microsoft Sometimes asked ~40 min walkthrough
01

Scope the requirements

Two problems hide inside this question: moving very large files reliably (the data plane) and telling every device what changed (the sync plane). Candidates who treat it as 'upload a file to S3' miss both, and the interviewer knows within five minutes.

Functional — what it must do

  • Upload files from any device — up to 50 GB, resumable after disconnects.
  • Download files from any device, ideally near the user.
  • Share files with other users with access control.
  • Automatic sync: edit a file on one device, see it on the others within seconds.
  • Explicitly cut: real-time co-editing (that's Google Docs), version history UI, virus scanning — name them and offer them back.

Non-functional — the numbers that shape the design

  • Availability over consistency: cross-device sync lag of a few seconds is fine; being unable to save is not.
  • Durability is sacred — eleven nines on stored bytes; losing a user's file is the one unforgivable failure.
  • A 50 GB upload must survive network drops without restarting — resumable by construction.
  • Encryption in transit and at rest; sharing enforced by ACL, not obscurity.
  • Scale: 500M users, 100M DAU, ~200 files each — on the order of 10 PB of content.
Say this out loud

I'll split this into a control plane and a data plane immediately: metadata and permissions go through my services, but file bytes go directly between the client and blob storage. If a 50 GB file ever flows through my app servers, the design is already wrong.

02

Back-of-envelope estimates

The numbers force the two big decisions: file size makes chunking non-optional, and total volume makes 'store it in the database' laughable — blob storage plus a metadata store is the only shape that works.

QuantityEstimateHow you got there
Worst-case upload time~1 hour50 GB at 100 Mbps ≈ 1.1 hours. No connection lives that long untouched — resumability is a requirement, not a feature.
Chunks per large file~10,00050 GB ÷ 5 MB chunks. Each chunk is independently uploadable, retryable and fingerprintable.
Total files~100 B500M users × 200 files. Most are small — average ~100 KB.
Content storage~10 PB100B files × ~100 KB. Blob storage territory; dedup will claw a meaningful slice back.
Metadata~100 TB100B files × ~1 KB of metadata each — too big for one box, fine for a partitioned store keyed by user.
Sync connections~1M concurrent100M DAU with a fraction online at once, each holding a cheap idle WebSocket or long-poll.
Say this out loud

One hour to upload a big file and ten petabytes to store settle the architecture on their own: chunked direct-to-blob uploads, a partitioned metadata store, and a CDN in front of downloads.

03

Sketch the API

The API's defining move is that upload and download endpoints return URLs, not bytes — presigned links that let clients talk to blob storage directly while my services stay in charge of who may do what.

POST/api/v1/filesbody: { name, size, fingerprint, chunks[] } → uploadId + presigned URL per chunk. Creates metadata in uploading state; dedupe check on fingerprint happens here.
PATCH/api/v1/files/{fileId}/chunksClient reports chunk completion; server verifies ETags against blob storage before trusting anything.
GET/api/v1/files/{fileId}→ metadata + short-lived signed CDN download URL (~5 min expiry).
POST/api/v1/files/{fileId}/sharebody: { userIds } → writes rows to the shared-files index; revocation deletes them.
GET/api/v1/changes?cursor=…→ ordered change events since the cursor. The pull half of sync; every device keeps its own cursor.
  • Presigned URLs are bearer tokens — anyone holding one can fetch the bytes. That's why they expire in minutes, are scoped to a single object and operation, and are minted per-request after an ACL check. Auth travels in headers, never in the body.
  • The changes feed is cursor-paginated, not timestamp-paginated — timestamps collide and skew across writers; an opaque cursor over a monotonic per-user sequence guarantees a device never misses or double-applies a change.
04

Data model

Metadata is small, hot and relational-ish; content is huge and opaque. Model them separately and never let file bytes near the metadata store.

files
file_id PK · owner_id · name · size · fingerprint (SHA-256) · status · created_at
Fingerprint is the identity of the content; file_id is the identity of the entry. Never conflate them — two users can own the same bytes.
chunks
file_id · chunk_index · fingerprint · etag · status
Tracks upload progress per chunk and enables both resume and chunk-level dedup.
shared_files
user_id PK · file_id SK · granted_by · granted_at
An inverted index: 'files shared with me' is one range read. A share-list array inside file metadata cannot answer that query and grows unboundedly.
changes
user_id · seq · file_id · op · ts
Append-only per-user log that backs the sync feed; devices consume it by cursor.
The database call

I'd start with Postgres partitioned by user_id — metadata access is always user-scoped, the ACL checks want transactions, and 100 TB partitions cleanly. Content goes to S3 with server-side encryption, fronted by a CDN. If metadata write volume outgrew Postgres partitioning, I'd move files and shared_files to DynamoDB with user_id as partition key — the access patterns are already key-value shaped, so I'd say that migration out loud rather than pre-paying for it.

05

High-level design

Control plane through the file service, data plane straight between client and S3, and a sync plane that pushes change notifications over WebSockets with polling as the safety net.

client edge service data async Device A sync agent Device B API gateway CDN signed URLs File service metadata, presign, ACL Sync service WebSocket push Metadata DB files, shares, changes S3 chunked blobs Event bus S3 events, changes metadata GET file init upload upload events push change origin fill mark uploaded PUT chunks
follow the bytes: file content only ever moves on the client↔S3 and CDN edges — no file data crosses the service lane.
  1. Device A's sync agent notices a changed file, fingerprints it and its chunks, and POSTs the manifest → the file service checks quota and dedup, records metadata in uploading state, and returns presigned URLs per chunk.
  2. The client uploads chunks in parallel, directly to S3, retrying individual chunks on failure; a dropped connection resumes from the chunk list, not from byte zero.
  3. S3 emits events as parts land → the file service verifies ETags against S3 (never trusting the client's report), marks the file uploaded, and appends a change record for every user with access.
  4. The sync service fans the change out over WebSockets to Device B and every other online device; offline devices catch up via GET /changes?cursor=… when they reconnect.
  5. Device B pulls only the changed chunks through the CDN using short-lived signed URLs and patches its local copy.
06

Deep dives — where the interview is won

Phase 01

Uploading 50 GB: chunked, parallel, resumable — and verified server-side

The client fingerprints the whole file and each 5 MB chunk, then initiates a multipart upload: the file service returns an uploadId and a presigned URL per part, the client uploads parts in parallel, and completion stitches them into one object. This buys three things at once: progress (per-chunk status), parallelism (saturate the uplink), and resume (a reconnecting client asks which chunks are missing and sends only those).

The detail that separates candidates: never trust the client's word that a chunk landed. The client PATCHes chunk status as a hint, but the file service confirms against S3's own part listing and ETags before marking the file uploaded. A malicious or buggy client can misreport; the blob store is the referee.

client s3 Uploader Chunk 1 Chunk 2 Chunk N Manifest commit
50 gb split into 4 mb chunks. client uploads in parallel, retries only failed chunks.
Trade-offSmaller chunks give finer resume granularity and better parallelism but multiply request overhead and metadata rows — 5–10 MB is the standard balance point; say why rather than naming a number cold.
Phase 02

Delta sync: content-defined chunking is the whole trick

When a user edits one paragraph of a large file, re-uploading everything is unacceptable. Chunk fingerprints solve it — compare local chunk hashes to the server's manifest and upload only what changed. But fixed-size chunks break on inserts: adding one byte at the front shifts every chunk boundary, so every hash changes and you're back to re-uploading the file.

The fix is content-defined chunking: a rolling hash (Rabin fingerprint) over a sliding window declares a chunk boundary wherever the hash hits a pattern, so boundaries are anchored to content, not offsets. An insert changes the chunks near the edit and nothing else. The same fingerprints give dedup for free — identical chunks across files, versions and even users are stored once and reference-counted.

versions chunks v1 v2 edited chunk A chunk B' changed chunk C reuse new reuse
content-defined chunking: rolling hash finds stable boundaries. edits touch only affected chunks.
Trade-offContent-defined chunking costs client CPU on every scan and produces variable-size chunks that complicate bookkeeping; cross-user dedup also leaks a bit of information (you can probe whether some exact content already exists), so encrypt-before-upload products have to give it up.
Phase 03

Sync down: push for speed, pull for truth

Real-time sync wants push: devices hold a WebSocket (or SSE stream) to the sync service, and a change fans out to online devices in under a second. But push alone is not a sync protocol — connections drop silently, servers restart, messages get lost in fan-out. Any device that treats a pushed event as the source of truth will eventually diverge and stay diverged.

So the durable mechanism is the per-user change log consumed by cursor: every device periodically calls GET /changes?cursor=… and applies whatever it missed, and the WebSocket push is best-effort — a hint meaning 'poll now'. This hybrid is the standard answer: push provides latency, pull provides correctness, and the cursor makes catch-up exact regardless of how long a device slept.

server client Sync svc Notify "you changed" Pull delta push GET diff
server pushes 'changed' notification; client pulls the actual delta. push is fast, pull is truth.
Trade-offHolding ~1M idle WebSockets costs a fleet of connection servers and state to route notifications to the right box; the polling fallback costs read traffic proportional to poll frequency. You pay both to get seconds-fast sync that never loses a change.
Phase 04

Conflicts: last-write-wins is a data-loss policy — say so

Two devices edit the same file offline and both sync. Pure last-write-wins silently discards one user's work, which for a file product is quietly catastrophic. Dropbox's answer is the right one to cite: detect the conflict (the uploading device's base version no longer matches the server's head version) and fork — keep both, renaming one to a 'conflicted copy'. No merge, no data loss, and the human resolves it with full information.

Detection is cheap: every upload carries the version (or content fingerprint) it was based on, and the metadata service compares atomically at commit time. This is optimistic concurrency control applied to files — worth naming, because it connects to how the interviewer's follow-ups about versioning will go.

writers server result Client A Client B Server last hash wins Kept Conflict copy
concurrent edits to same file → last write wins on chunk-hash. UI surfaces the loser as a conflict copy.
Trade-offConflicted copies push resolution work onto users and multiply storage for contested files — but for opaque binary content, any automatic merge is a guess, and a wrong guess is deleted work. Real merging needs operational transforms, which is the Google Docs question, not this one.
07

Follow-ups you should expect

Why availability over consistency here? A file system feels like it should be consistent.
The unit of collaboration is coarse — whole files, usually one author at a time — so seconds of sync lag are invisible, while refusing to accept a save because a quorum is unreachable is a visible failure. Durability is where I'm strict; ordering across devices is where I'm relaxed. A banking document store would flip that, and I'd say so.
How does dedup interact with 'delete my file'?
Chunks are reference-counted: deleting a file decrements refs on its chunks and only zero-ref chunks are eligible for garbage collection, after a grace period for undelete. For hard privacy guarantees (GDPR erasure), per-user encryption keys are the cleaner tool — destroy the key and the shared-chunk question becomes moot for that user's unique content.
What if the S3 upload event never arrives — the file sits in 'uploading' forever?
Events are a latency optimisation, not the source of truth. A reconciliation job sweeps stale uploading records, asks S3 directly which parts exist, and either completes or expires the upload. The client also re-PATCHes status on reconnect. Every async signal in the design has a polling truth-check behind it.
How do you make downloads fast for a user in Singapore when the file was uploaded in Virginia?
CDN in front of S3 with signed URLs — first fetch in a region is an origin fill, subsequent ones are edge hits. For sync workloads where a shared file is about to be fetched by a whole team, that first-hit cost dominates, so popular shared content effectively pre-warms itself. Cross-region S3 replication is the heavier tool I'd hold back until latency data justifies it.
Where does compression fit?
Client-side, before upload, and selectively: text and documents compress several-fold with Zstandard, while media formats are already compressed and only waste CPU. The sync agent decides per file type. If we ever do client-side encryption, compression must happen first — encrypted bytes don't compress.

Where candidates lose the room

  • Routing file bytes through app servers instead of presigned direct-to-S3 — the single most common failure; it turns your service tier into a 10 PB proxy.
  • Chunking server-side after receiving the whole file, which defeats every reason chunking exists (resume, parallelism, delta sync).
  • Fixed-size chunks when asked about syncing edits — one inserted byte shifts every boundary and forces a full re-upload; the interviewer is fishing for content-defined chunking.
  • Trusting client-reported chunk status without verifying ETags against the blob store — a correctness and security hole in one.
  • Sync via WebSocket push alone (diverges on dropped connections) or polling alone (seconds-to-minutes of lag) — the expected answer is push for latency plus cursor-based pull for truth.
  • Using filename or path as the file's identity instead of an ID plus content fingerprint — renames become copies and dedup becomes impossible.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.