Skip to the solution
HardSystem Design16 min read

How would you design a file storage service like Dropbox?

874 views

The problem

Question presented to candidate: "Design a file sync service like Dropbox. A user installs a client on a laptop and a phone; any file they put in the synced folder should appear on every other device within seconds, edits should upload quickly even for big files, and nothing should be lost if two devices edit the same file while offline. How would you build the client, the servers and the storage?"

What a strong answer should cover:

  • Block-level sync: split files into blocks (Dropbox uses blocks of up to 4 MB identified by SHA-256), upload only blocks the server lacks; this gives delta sync and deduplication, and a file becomes an ordered list of block hashes
  • Separate the block store (content-addressed, immutable, object storage) from the metadata service (namespaces, paths, revisions, block lists) and a notification service that tells idle clients to fetch changes
  • A per-namespace journal with monotonically increasing sequence numbers; each client keeps a cursor and pulls everything after it, which makes sync resumable and ordered
  • Commit protocol: the client commits "path now has these blocks, based on revision X"; the server answers with missing blocks or a conflict when the base revision is stale, and the client then saves a conflicted copy
  • Chunking trade-off: fixed-size blocks re-upload everything after an insert near the start; content-defined chunking keeps boundaries stable
  • Numbers: users, storage, commits per second, long-poll connections, block index size, and what breaks at 10x

Clarifying questions expected:

  • How many users and devices, average storage per user, typical file sizes?
  • Is real-time collaboration on the same document in scope, or only file sync?
  • Do we need sharing, version history, and LAN sync?

Code / implementation expected: Optional โ€” the design and the sync protocol come first; the examples compare chunking strategies and simulate the journal, cursors and a conflict.

  • file-storage

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

16 min read ยท 15 sections

Read the code

Fixed vs content-defined chunking: SHA-256 blocks, dedup, delta upload after an overwrite and an insert
// Block-level sync: fixed-size vs content-defined chunking, SHA-256 block hashes, dedup and delta upload.
const crypto = require("crypto");
let seed = 2463534242;
function rnd() { seed ^= seed << 13; seed >>>= 0; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed; }
const GEAR = []; for (let i = 0; i < 256; i++) GEAR.push(rnd());
const file = Buffer.alloc(200000); for (let i = 0; i < file.length; i++) file[i] = rnd() & 255;

const FIXED = 16384, MIN = 4096, MAX = 32768, MASK = 0x3fff;   // 16 KB blocks stand for 4 MB blocks
function fixedChunks(b) { const out = []; for (let i = 0; i < b.length; i += FIXED) out.push([i, Math.min(i + FIXED, b.length)]); return out; }
// Gear rolling hash: cut where the low 14 bits of the hash are zero, so boundaries depend on content, not offset.
function cdcChunks(b) {
  const out = []; let start = 0, h = 0;
  for (let i = 0; i < b.length; i++) {
    h = ((h << 1) + GEAR[b[i]]) >>> 0;
    const len = i - start + 1;
    if ((len >= MIN && (h & MASK) === 0) || len >= MAX) { out.push([start, i + 1]); start = i + 1; h = 0; }
  }
  if (start < b.length) out.push([start, b.length]);
  return out;
}
const hash = (b) => crypto.createHash("sha256").update(b).digest("hex").slice(0, 12);

// Server side: block store keyed by hash (dedup), and a file = ordered list of block hashes.
function makeServer() { return { blocks: new Map() }; }
function sync(server, b, chunker) {
  const list = chunker(b).map(([s, e]) => [hash(b.subarray(s, e)), e - s]);
  let newBlocks = 0, bytes = 0;
  for (const [h, len] of list) if (!server.blocks.has(h)) { server.blocks.set(h, len); newBlocks++; bytes += len; }
  return { list, newBlocks, bytes };
}

const edit = Buffer.from(file); for (let i = 0; i < 100; i++) edit[100000 + i] = 0x41;   // overwrite 100 bytes in the middle
const insert = Buffer.concat([file.subarray(0, 1000), Buffer.from("INSERTED!!"), file.subarray(1000)]);   // insert 10 bytes near the start

for (const [name, chunker] of [["fixed 16 KB", fixedChunks], ["content-defined", cdcChunks]]) {
  const server = makeServer();
  console.log(`== ${name} ==`);
  const v1 = sync(server, file, chunker);
  const sizes = v1.list.map(([, l]) => l);
  console.log(`v1 upload:        ${String(v1.list.length).padStart(2)} blocks, ${String(v1.newBlocks).padStart(2)} new, ${String(v1.bytes).padStart(6)} bytes sent (block sizes ${Math.min(...sizes)}..${Math.max(...sizes)})`);
  const v1b = sync(server, file, chunker);
  console.log(`same file again:  ${String(v1b.list.length).padStart(2)} blocks, ${String(v1b.newBlocks).padStart(2)} new, ${String(v1b.bytes).padStart(6)} bytes sent (dedup)`);
  const v2 = sync(server, edit, chunker);
  console.log(`overwrite 100 B:  ${String(v2.list.length).padStart(2)} blocks, ${String(v2.newBlocks).padStart(2)} new, ${String(v2.bytes).padStart(6)} bytes sent`);
  const v3 = sync(server, insert, chunker);
  console.log(`insert 10 B:      ${String(v3.list.length).padStart(2)} blocks, ${String(v3.newBlocks).padStart(2)} new, ${String(v3.bytes).padStart(6)} bytes sent`);
  let stored = 0; for (const l of server.blocks.values()) stored += l;
  console.log(`block store after 3 versions: ${server.blocks.size} blocks, ${stored} bytes (3 full copies would be ${file.length + edit.length + insert.length})`);
}
Sync journal with cursors: missing-block handshake, offline edit conflict, conflicted copy, tombstone, convergence
// Sync metadata: a per-namespace journal, device cursors, missing-block handshake, conflicts, tombstones.
const server = { journal: [], files: new Map(), blocks: new Set() };   // files: path -> {rev, blocks}
let revCounter = 0;

// Commit = "path now points at this block list, and I based it on rev X". Server checks both.
function commit(device, path, blocks, baseRev) {
  const missing = (blocks || []).filter((b) => !server.blocks.has(b));
  if (missing.length) return { status: "need_blocks", missing };
  const cur = server.files.get(path);
  const curRev = cur ? cur.rev : null;
  if (curRev !== baseRev) return { status: "conflict", current: curRev };
  const rev = "r" + ++revCounter;
  if (blocks === null) server.files.delete(path); else server.files.set(path, { rev, blocks });
  server.journal.push({ seq: server.journal.length + 1, path, rev, blocks, by: device });
  return { status: "ok", rev, seq: server.journal.length };
}
function upload(blocks) { blocks.forEach((b) => server.blocks.add(b)); }

class Device {
  constructor(name) { this.name = name; this.cursor = 0; this.files = new Map(); }
  // Long-poll returns as soon as the journal moves past my cursor; then I list the new entries.
  pull() {
    const entries = server.journal.slice(this.cursor);
    for (const e of entries) { if (e.blocks === null) this.files.delete(e.path); else this.files.set(e.path, { rev: e.rev, blocks: e.blocks }); }
    this.cursor = server.journal.length;
    return entries.map((e) => `${e.seq}:${e.path}${e.blocks === null ? " (deleted)" : ""}`);
  }
  save(path, blocks) {
    const base = this.files.has(path) ? this.files.get(path).rev : null;
    let r = commit(this.name, path, blocks, base);
    if (r.status === "need_blocks") { console.log(`  ${this.name}: server needs [${r.missing.join(",")}], uploading`); upload(r.missing); r = commit(this.name, path, blocks, base); }
    if (r.status === "conflict") {
      const copy = path.replace(".txt", ` (${this.name} conflicted copy).txt`);
      console.log(`  ${this.name}: ${path} conflict (based on ${base}, server has ${r.current}) -> saved as ${copy}`);
      return this.save(copy, blocks);
    }
    this.files.set(path, { rev: r.rev, blocks });
    console.log(`  ${this.name}: ${path} -> ${r.rev} at seq ${r.seq}`);
  }
  remove(path) { const r = commit(this.name, path, null, this.files.get(path).rev); this.files.delete(path); console.log(`  ${this.name}: delete ${path} -> tombstone at seq ${r.seq}`); }
  show() { return [...this.files.entries()].sort().map(([p, f]) => `${p}@${f.rev}[${f.blocks.join(",")}]`).join("  "); }
}

const laptop = new Device("Laptop"), phone = new Device("Phone");
console.log("== 1. Laptop adds three files; the server asks only for blocks it lacks ==");
laptop.save("/notes.txt", ["h1", "h2"]);
laptop.save("/old.txt", ["h3"]);
laptop.save("/copy.txt", ["h1", "h2"]);
console.log(`Phone pulls from cursor ${phone.cursor}: ${phone.pull().join(", ")} -> cursor ${phone.cursor}`);

console.log("== 2. Both edit /notes.txt offline from r1 ==");
laptop.save("/notes.txt", ["h1", "h4"]);
phone.save("/notes.txt", ["h5", "h2"]);
console.log("== 3. Laptop deletes /old.txt ==");
laptop.remove("/old.txt");
console.log("== 4. Everyone catches up ==");
console.log(`Laptop pulls from cursor ${laptop.cursor}: ${laptop.pull().join(", ")}`);
console.log(`Phone pulls from cursor ${phone.cursor}: ${phone.pull().join(", ")}`);
console.log(`Laptop: ${laptop.show()}`);
console.log(`Phone:  ${phone.show()}`);
console.log(`converged: ${laptop.show() === phone.show()}, journal length ${server.journal.length}, blocks stored ${server.blocks.size}`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 66 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

What is KV-cache and how does prefix caching speed up LLM inference?

Continue