Skip to content
Software Atlas

Design File Sync

A folder that stays the same on every device: split metadata from bytes, cut files into content-defined chunks, store each chunk once, upload only what's missing, and handle offline edits without losing anyone's work.

⏱ ~45 min read14 sections8 interactive widgetsGo · JavaScript · SQL
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA file-sync service keeps one folder identical on all of a person's devices. The trick is to treat a file as two things: a small record of what the file is (name, version, list of pieces) and a pile of pieces of bytes, each stored once under its own fingerprint.

You've used one: a "cloud drive" folder on your laptop, phone and the web. Save a file on the laptop, and a few seconds later the phone has it. Edit it on a plane, and it syncs when you land. That looks simple, but files can be gigabytes, networks drop, and two devices can edit the same file while both are offline.

The whole design on one page
💻 laptop
edits report.pdf
📱 phone
waits for changes
metadata service + database
files, versions, chunk lists
"report.pdf v8 = chunks A, B, C′"
change log
#41 put report.pdf v8
devices read "after my cursor"
blob store
chunks, keyed by hash
A · B · C · C′ · …
each stored once
Every arrow is something this page explains. Devices talk to the API for metadata and to the blob store for bytes; the change log tells other devices what to fetch.

The five ideas this page builds

  1. Split metadata from content (§04). File names and versions go in a database; the bytes go in cheap Object storage.
  2. Cut files into chunks (§05). Edit one paragraph of a 2 GB video project, and only the chunk around it changes. Upload that, not 2 GB.
  3. Name each chunk by its hash and store it once (§06). Identical chunks, in old versions, copies or other files, are kept once.
  4. Upload what's missing, then commit (§07–§08). Ask "which of these chunks do you lack?", send those, then make the new version visible in one step. Other devices learn about it from a change log.
  5. Never silently lose an edit (§09–§11). When two devices edit the same file offline, notice it and keep both versions.
Foundations§02

What are we building?

In one sentenceBefore drawing boxes, write down what the product must do and how well it must do it. For file sync, one non-functional requirement outranks all others: never lose a user's data.

Requirements come in two kinds. Functional ones describe what a user can do. Non-functional ones describe how well the system does it: how fast, how reliable, how cheap. The second list is usually where the hard design problems hide.

Functional: what a user can do

  • Sync a folder. Put a file in the folder on one device, and it appears on all the user's other devices. Edits and deletes sync the same way.
  • Large files. A 10 GB video must work, on a phone connection too.
  • Offline edits. Edit files with no network. They sync when the device reconnects, and the system must notice when two devices edited the same file meanwhile (§09).
  • Version history. See and restore older versions of a file, say for the last 30 days. This also rescues people from accidental deletes and ransomware.
  • Sharing (stretch goal). Share a folder with another user, so it syncs into their account too. We'll mark where sharing changes the design (§10), but won't build it fully.

Out of scope, and worth saying out loud: real-time co-editing of documents (that's a different product, closer to chat), search inside files, and photo galleries.

Non-functional: how well

RequirementWhat it means hereWhy it shapes the design
Never lose dataOnce the app says "synced", the file survives disk, server and data-centre failures. No edit is overwritten silently.Needs Durability in storage (§04) and careful conflict handling (§09).
Upload only what changedEditing 1 MB of a 1 GB file sends about 1 MB, not 1 GB.Leads straight to chunking and hashing (§05–§06).
Work on bad networksA dropped connection resumes; it doesn't restart a 10 GB upload.Leads to resumable uploads (§07).
Sync feels quickA small edit shows up on other online devices within a few seconds.Leads to push notifications of changes (§08).
Cheap per GBUsers store a lot and read little of it.Leads to deduplication and storage tiers (§06, §10).

A note on "the same everywhere"

How quickly must devices agree? Not instantly. If the phone shows the old file for five seconds after the laptop saves, nobody minds. That's Eventual consistency: copies may differ for a short while, but they converge once changes stop. What we must never do is end up with devices that disagree forever, or quietly drop one of two edits. Keep those two promises apart; the rest of the design depends on it.

Foundations§03

How big is it? A rough estimate

In one sentenceA back-of-the-envelope estimate uses round numbers to find which part of the system is big. For file sync the surprise is that bytes are huge but simple, while metadata is small but busy.

All numbers below are approximate and invented for a mid-sized cloud drive. They're not any real company's figures. Being within 10× is good enough: we only want to know which numbers are scary.

Step 1: the inputs

  • 10 million users. On a given day 20% of them change something: 2 million daily active users.
  • Each user has 1,000 files averaging 1 MB. (A few huge videos, lots of small documents.)
  • Each active user changes 10 files a day. Thanks to chunking (§05), each change uploads about 1 MB on average.
  • Each user has 3 devices, so every change must be downloaded by 2 other devices.
  • Identical chunks are stored once (§06). Assume that saves a third: a dedup ratio of 1.5.
  • Old versions are kept 30 days. The busiest hour is about 3× the daily average.

Step 2: storage

What users think they store: 10,000,000 users × 1,000 files × 1 MB = 10,000,000,000 MB = 10 PB (1 PB = 1,000 TB = 1,000,000 GB).

After deduplication: 10 PB ÷ 1.5 ≈ 6.7 PB.

Version history: each day, new bytes arrive: 2,000,000 users × 10 changes × 1 MB = 20,000,000 MB = 20 TB a day. Kept 30 days: 20 TB × 30 = 600 TB.

So we store about 7.3 PB of unique data, before the blob store makes its own spare copies to survive broken disks (§10). That's far more than any one machine holds, so the bytes go into Object storage, which is built for exactly this.

Step 3: requests and bandwidth

Commits (one per changed file): 2,000,000 × 10 = 20,000,000 a day. 20,000,000 ÷ 86,400 seconds ≈ 230 commits/s on average, ≈ 700/s at peak.

Upload bandwidth: 20 TB a day ÷ 86,400 s ≈ 230 MB/s. Multiply by 8 for bits: ≈ 1.9 Gbit/s on average, ≈ 5.6 Gbit/s at peak.

Download bandwidth: every change goes to 2 other devices, so 40 TB a day ≈ 3.7 Gbit/s on average, ≈ 11 Gbit/s at peak.

Waiting devices: 2,000,000 active users × 3 devices = 6,000,000 devices. If about half are online at once, that's 3 million open connections waiting to hear "something changed" (§08).

Step 4: metadata

Files: 10,000,000 users × 1,000 files = 10 billion file records. At roughly 300 bytes each (the path and IDs, plus the current version's row with its chunk list) that's 10,000,000,000 × 300 B = 3 TB.

Old versions: 20,000,000 new versions a day × 30 days = 600 million rows. At about 200 bytes each ≈ 120 GB.

Chunk index (one row per unique chunk, §06): most files are smaller than one chunk, so roughly one chunk per file, minus dedup: 10 billion ÷ 1.5 ≈ 7 billion rows × ~80 bytes ≈ 560 GB.

Change log (§08), kept 90 days: 20,000,000 a day × 90 = 1.8 billion rows × ~40 bytes ≈ 72 GB.

Total: 3 TB + 120 GB + 560 GB + 72 GB ≈ 3.75 TB, call it 4 TB of metadata. That fits on a few large database servers, but not comfortably on one, and every commit writes to it.

Play with the inputs

Try a dedup ratio of 3 (lots of shared files in a company), or keep history for 365 days, and watch which number grows fastest.

Capacity estimator
Change the inputs; every output shows its formula. Red marks a number big enough to shape the design.
Files as users see them10.0 PB
10 million users × 1000 files × 1 MB
Stored after dedup6.7 PB
10.0 PB ÷ 1.5
Version history600 TB
2.0 million DAU × 10 changes × 1 MB × 30 days
Commits per second231 avg · 694 peak
2.0 million × 10 ÷ 86,400 (peak ×3)
Upload bandwidth1.9 Gbit/s avg · 5.6 peak
20.0 TB/day × 8 bits ÷ 86,400 s
Download bandwidth3.7 Gbit/s avg
upload × 2 other devices
Open connections3.0 million
2.0 million × 3 devices × ½ online
Metadata3.7 TB
files × 300 B + old versions × 200 B + chunk index (files ÷ 1.5) × 80 B
All approximate. 1 PB = 1,000 TB = 1,000,000 GB. DAU = users × active %. The blob store's own spare copies (§10) come on top of “stored”.
Foundations§04

Version one: split metadata from bytes

In one sentenceStore what a file is (name, version, list of chunks) in a database, and the bytes in a blob store. They have opposite needs, so they get different homes.

The naive first try

The very first idea is "upload the file to the server, and the server saves it on its disk under the user's folder". It works for a demo. Then it fails every requirement from §02: editing one line of a 1 GB file re-uploads 1 GB, a dropped connection restarts from zero, the server's one disk is a single point of failure, and two copies of the same file take twice the space.

The fix starts with noticing that a "file" is really two different kinds of data.

Two kinds of data, two homes

MetadataContent (bytes)
Example"report.pdf, version 8, 3 MB, chunks A, B, C′"the 3 MB themselves
Size~300 bytes per file, a few TB in total~1 MB per file, petabytes in total
How it changesMany small updates that must agree with each otherWritten once, never edited (a new version = new chunks)
What it needsTransactions, queries like "list this folder"Cheap, durable storage of big blobs by key
HomeA relational database (Postgres, MySQL)An object store (a blob store)

A database is great at small rows that change together, and bad at storing gigabytes of video. An object store (Cloud Basics: managed services) is the opposite: it holds unlimited bytes cheaply and keeps several copies for durability, but it can't do "update these three things together or not at all". So each gets the job it's good at.

Chunks are immutable: once written, a chunk's bytes never change. Editing a file creates new chunks and a new list; the old ones stay for version history. Immutable data is easy to copy, cache and keep durable, because there's never an "old copy" that's out of date.

Version one
sync client
watches the folder, chunks and hashes files, keeps a local index
API server
check chunks · commit · list changes
Postgres
files · file_versions · chunks · changes
object store bucket
chunks/<sha256>/<upload id>
One API server is enough to start. The client sends metadata requests to the API and chunk bytes to the blob store; the database and blob store never talk to each other directly.

The sync client, the app on each device, does more work than you might think. It watches the folder for changes, cuts files into chunks, hashes them, and keeps a small local database of "which chunks make up which file" so it can tell what changed without asking the server.

The data model

Here's the metadata schema, in SQL for Postgres. Read the comments first; the details come back in later sections.

schema.sql
CREATE TABLE namespaces (            -- one folder tree, e.g. "Ana's files"
  id       bigint PRIMARY KEY,
  last_seq bigint NOT NULL DEFAULT 0 -- newest change-log number (§08)
);

CREATE TABLE files (
  id              bigserial PRIMARY KEY,
  namespace_id    bigint  NOT NULL REFERENCES namespaces(id),
  path            text    NOT NULL,           -- "/Reports/q3.pdf"
  current_version int     NOT NULL,
  deleted         boolean NOT NULL DEFAULT false, -- a tombstone (§11)
  UNIQUE (namespace_id, path)
);

CREATE TABLE file_versions (
  file_id    bigint      NOT NULL REFERENCES files(id),
  version    int         NOT NULL,            -- 1, 2, 3… per file
  size       bigint      NOT NULL,
  chunks     text[]      NOT NULL,            -- ← the manifest: chunk hashes in order
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (file_id, version)
);

CREATE TABLE chunks (                -- which chunks the blob store really has
  hash       text        PRIMARY KEY,  -- SHA-256, as 64 hex characters
  size       int         NOT NULL,
  object_key text        NOT NULL,     -- where the bytes are in the object store (§12)
  last_used  timestamptz NOT NULL DEFAULT now() -- for safe garbage collection (§12)
);

CREATE TABLE changes (               -- the change log (§08)
  namespace_id bigint NOT NULL,
  seq          bigint NOT NULL,      -- 1, 2, 3… per namespace
  file_id      bigint NOT NULL,
  version      int    NOT NULL,
  op           text   NOT NULL,      -- 'put' or 'delete'
  PRIMARY KEY (namespace_id, seq)
);

A namespace is one folder tree with its own history, usually "one user's files" (later, also "one shared folder"). Almost every query is scoped to a single namespace: list my folder, give me my changes. Remember that; it decides how we split the database in §10.

Practitioner§05

Chunking: fixed-size vs content-defined

In one sentenceChunking cuts a file into pieces so you can upload, store and resume piece by piece. Where you cut matters: cut every N bytes and one inserted byte changes every chunk after it; cut where the content says so and only one chunk changes.

Chunking gives us three things at once. Upload only what changed: re-send the chunks that differ. Resume: after a dropped connection, continue from the next chunk. Store once: identical chunks are kept a single time (§06). Everything depends on one property: a small edit should change few chunks.

Fixed-size chunks, and the one-byte problem

The obvious way is to cut every N bytes: the first 4 MB are chunk 1, the next 4 MB are chunk 2, and so on. If you overwrite a few bytes in the middle, only the chunk containing them changes. Great.

Now insert one byte at the start, like a word added to the first line of a document. Every byte moves one position to the right. Chunk 1 now ends one byte earlier in the old content, chunk 2 starts one byte earlier, and so on: every chunk's bytes are different, so every hash is different, so the whole file uploads again.

Insert one byte (★) at the start
fixed size 4
the cat sat on mat
★the cat sat on mat← all 5 new
content-defined (cut after a space)
the␣cat␣sat␣on␣mat
★the␣cat␣sat␣on␣mat← 1 new, 4 reused
Top: fixed-size chunks of 4 letters. After the insert, every chunk holds different letters, so all of them are new. Bottom: content-defined chunks end after each space here (a stand-in for “where the hash says so”). Only the first chunk changes. Real chunkers with a minimum size may change the next one or two as well.

Content-defined chunking

"Cut after every space" only works for text, and gives tiny chunks. Content-defined chunking generalizes it to any bytes:

  1. Slide a small window (say the last 48 bytes) along the file, one byte at a time.
  2. At each position, compute a hash of just the bytes in the window.
  3. If the hash matches a pattern that happens about once every N positions (for example "the top 12 bits are all zero", true about 1 time in 4,096), cut here.

The decision at each position depends only on the bytes in the window. Insert a byte at the start, and the windows further along still contain the same bytes as before, so they make the same decisions: the cut points move along with the content. Only the chunks around the edit change. In effect the file grows its own "spaces".

The average chunk size is set by the pattern: "the top k bits are zero" happens about once every 2k positions. 12 bits gives chunks of about 4 KB (2¹² = 4,096 bytes); about 22 bits gives about 4 MB (2²² = 4,194,304).

Recomputing a 48-byte hash at every one of a billion positions would be slow. A rolling hash makes each step cheap: when the window moves forward one byte, add the new byte and subtract the one that fell out, instead of hashing the whole window again.

Chunking lab
Version 1 of the file is already stored. Edit the text (or use a button) and see which chunks of the new version are new (red) and must be uploaded. · shows a space; the small code is each chunk's fingerprint.
fixed size: every 12 charactersupload 0 of 10 chunks · 0 B
Ana·ships·th
9d55ba
e·sync·clien
5e4df2
t·on·Friday.
977631
·Ben·fixes·t
473442
he·retry·bug
428b15
.·Chen·asks·
fae0b8
about·quotas
03773e
.·Next·meeti
20a9db
ng:·Tuesday·
c320e8
at·ten.
73fe0a
content-defined: rolling hash of the last 5 charactersupload 0 of 11 chunks · 0 B
Ana·sh
afa0be
ips·the·s
369dbe
ync·client·o
2d9b41
n·Frida
3b0876
y.·B
944a9c
en·fixes·the·retry·bug.·
464054
Chen·ask
9b1467
s·about·quot
ff0e63
as.·
e20e43
Next·me
acfb1a
eting:·Tuesday·at·ten.
ae5b45
Try “insert ! at the start”: every fixed-size chunk turns red, while content-defined chunking re-syncs after a few chunks (3 here). Because of the minimum size, each cut also depends on where the previous cut was, so the cuts only line up again once one lands in exactly the old place. The content-defined chunker here cuts where the hash of the last 5 characters hits a pattern that occurs about 1 time in 8, with a minimum chunk of 4 and a maximum of 24 characters.

Two guard rails make real chunkers practical. A minimum size stops the chunker from cutting twice in a row and making 3-byte chunks. A maximum size forces a cut in boring data, such as a long run of zeros, where the pattern might never appear.

The minimum size has a side effect you can see in the lab: a cut is skipped if it's too close to the previous cut, so each cut depends a little on the one before. After an edit, the next few chunks can change before a cut lands in exactly its old place and everything lines up again. Still far better than "every chunk after the edit".

A chunker you can run

This program chunks a short text both ways, inserts one byte at the start, and counts how many of the new chunks were already stored. Chunk sizes are tiny so the output is readable; the logic is the same at 4 MB.

chunks.go
package main

import (
	"crypto/sha256"
	"fmt"
)

const (
	window  = 8  // the rolling hash looks at the last 8 bytes
	minSize = 16 // never cut a chunk shorter than this…
	maxSize = 96 // …and always cut by this length
)

// cdcChunks cuts data wherever the hash of the last `window` bytes
// has its top 4 bits all zero: about 1 position in 16.
func cdcChunks(data []byte) [][]byte {
	var pow uint32 = 1 // 31^window, used to drop the oldest byte
	for range window {
		pow *= 31
	}
	var chunks [][]byte
	var h uint32
	start := 0
	for i, b := range data {
		h = h*31 + uint32(b) // add the newest byte…
		if i >= window {
			h -= pow * uint32(data[i-window]) // …and remove the oldest one
		}
		size := i + 1 - start
		if (size >= minSize && h>>28 == 0) || size >= maxSize {
			chunks = append(chunks, data[start:i+1]) // ← boundary depends only on nearby bytes
			start = i + 1
		}
	}
	if start < len(data) {
		chunks = append(chunks, data[start:])
	}
	return chunks
}

func fixedChunks(data []byte, size int) [][]byte {
	var chunks [][]byte
	for len(data) > size {
		chunks = append(chunks, data[:size])
		data = data[size:]
	}
	return append(chunks, data)
}

func report(name string, v1, v2 [][]byte) {
	stored := map[[32]byte]bool{}
	for _, c := range v1 {
		stored[sha256.Sum256(c)] = true
	}
	reused := 0
	for _, c := range v2 {
		if stored[sha256.Sum256(c)] {
			reused++
		}
	}
	fmt.Printf("%-16s v2 has %d chunks, %d already stored → upload %d\n",
		name, len(v2), reused, len(v2)-reused)
}

func main() {
	v1 := []byte("Meeting notes. Ana will ship the sync client on Friday. " +
		"Ben is fixing the upload retry bug. Chen asked about quotas for " +
		"shared folders. Next meeting: Tuesday at ten, same room as last week.")
	v2 := append([]byte("!"), v1...) // insert ONE byte at the very start

	report("fixed (32 B):", fixedChunks(v1, 32), fixedChunks(v2, 32))
	report("content-defined:", cdcChunks(v1), cdcChunks(v2))
}

Output (Go 1.24 and Node 22 print the same):

fixed (32 B):    v2 has 6 chunks, 0 already stored → upload 6
content-defined: v2 has 8 chunks, 7 already stored → upload 1

How big should a chunk be?

There's a trade-off. A 1 GB file makes 1 GB ÷ 4 MB = 256 chunks at 4 MB, or 1 GB ÷ 64 KB = 16,384 chunks at 64 KB (counting in powers of two here, 1 GB = 1,024 MB, so the numbers come out round). Smaller chunks find more duplicates and re-upload less after an edit, but they mean 64× more hashes to send, more rows in the chunk index, and more requests to the blob store. Most designs land on chunks of hundreds of KB to a few MB. One public data point: Dropbox's API documents a content_hash built from fixed-size 4 MiB blocks (4,194,304 bytes), each hashed with SHA-256 (Dropbox developer docs, "Content hash"). That's a fixed-size scheme for a checksum; it says nothing about whether their sync uses content-defined chunks.

Practitioner§06

Hashing and deduplication

In one sentenceGive every chunk a name computed from its bytes, its SHA-256 hash, and keep a single copy per name. Identical chunks, wherever they come from, then cost nothing extra to store or upload.

A chunk's name is its fingerprint

A SHA-256 hash turns any bytes into a 32-byte fingerprint. Same bytes, same fingerprint; change one byte, and the fingerprint is completely different. (You may know the same trick as a Content hash in build tools; here we apply it to chunks instead of whole files.) For the background on hash functions and why SHA-256 is safe to rely on, see Crypto Basics: hashes.

Storing data under a key that is its hash is called content-addressed storage. (The object store's physical key will get an extra upload ID in §12, for safe deletes; the hash stays the chunk's name everywhere else.) It gives us three things for free:

  • Duplicates find themselves. Before storing a chunk, look up its hash. Already there? Don't store it again.
  • Corruption is detectable. Whoever reads chunk e76d… can hash the bytes they got and check the result is e76d… (§11).
  • Chunks never need updating. A changed chunk has a new hash, so it's a new object. Nothing is ever overwritten, which is why chunks are immutable (§04).

A file version is then just a manifest: the list of its chunk hashes, in order. Git stores files the same way: see Git Internals.

Store once: a tiny deduplicating store

This program saves three files into a store keyed by SHA-256: a note, an edited copy of it, and an exact copy. Watch the "new" count.

store.go
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
)

const chunkSize = 16 // real systems use ~4 MB; tiny here so we can see it

// store maps a chunk's hash to its bytes: each unique chunk is kept once.
var store = map[string][]byte{}

// save splits a file into chunks, stores the new ones,
// and returns the file's manifest: the list of chunk hashes.
func save(name string, file []byte) []string {
	var manifest []string
	added := 0
	for start := 0; start < len(file); start += chunkSize {
		chunk := file[start:min(start+chunkSize, len(file))]
		sum := sha256.Sum256(chunk)
		key := hex.EncodeToString(sum[:])
		if _, ok := store[key]; !ok { // ← dedup: skip chunks we already have
			store[key] = chunk
			added++
		}
		manifest = append(manifest, key[:8]) // short prefix, for printing
	}
	fmt.Printf("%-8s %d chunks, %d new  %v\n", name, len(manifest), added, manifest)
	return manifest
}

func main() {
	v1 := []byte("Dear team, the offsite is on May 4. Bring a laptop.")
	v2 := []byte("Dear team, the offsite is on May 4. Bring a jacket.")
	save("v1", v1)
	save("v2", v2)   // same start, new ending
	save("copy", v1) // an identical file: nothing new to store
	fmt.Println("unique chunks stored:", len(store))
}

Output (Go 1.24 and Node 22 print the same):

v1       4 chunks, 4 new  [e76d12da 7362214a 6ef382a2 fa50339d]
v2       4 chunks, 2 new  [e76d12da 7362214a c3585e52 86f8e976]
copy     4 chunks, 0 new  [e76d12da 7362214a 6ef382a2 fa50339d]
unique chunks stored: 6

Three files, twelve chunks in their manifests, but only six stored. The edited version shares its first two chunks with the original; the copy shares all four.

How far should dedup reach?

Deduplication can work at three widening scopes:

  1. Across versions of one file. Version 8 reuses the chunks of version 7 it didn't change. This is the big, safe win; it's what makes 30 days of history affordable.
  2. Across one user's files. The same PDF saved in two folders, or a renamed file, costs nothing. Also safe.
  3. Across all users. A popular installer or a viral video is stored once for everyone. Biggest savings, but it has a cost.

The catch with cross-user dedup

If the server tells clients "you don't need to upload chunk X, we already have it", then anyone can ask whether anyone else has stored chunk X. That's a side channel: information leaks through behaviour (an upload that finishes instantly) instead of through an answer.

A concrete attack: a document template is known except for one field, say a 4-digit PIN in a bank letter. An attacker builds all 10,000 versions and uploads each one. The version that "uploads instantly" is the one the victim stored. This was described in "Side Channels in Cloud Services: Deduplication in Cloud Storage" (Harnik, Pinkas and Shulman-Peleg, IEEE Security & Privacy, 2010).

Common defences, each with a cost:

  • Dedup only within one user (or one company account) on the client-visible path. You lose the cross-user savings in bandwidth.
  • Dedup cross-user on the server side only. The client always uploads; the server quietly keeps one copy. Saves storage, not bandwidth, and leaks nothing through timing.
  • Make clients prove they have the file, not just its hash, before skipping an upload: proof of ownership. More complex.
Practitioner§07

Uploading: ask, send what's missing, commit

In one sentenceSaving a file is three requests: "which of these chunks are you missing?", upload those, then "commit: version 8 is this list of chunks". Until the commit, nobody sees anything; after it, everyone can.

The protocol, step by step

Say you edit report.pdf on your laptop. It was version 7: chunks A B C D. Your edit changed the third chunk and added one at the end.

  1. Chunk and hash locally. The client cuts the new file into chunks (§05) and hashes each one: A B C′ D E. It needs no network for this.
  2. Ask what's missing. POST /chunks/check with the five hashes. The server looks them up in its chunks table and answers: missing C′ and E.
  3. Upload the missing chunks. PUT each one. The receiver recomputes the SHA-256 of the bytes and rejects them if it doesn't match the hash the client claimed. Only then is the hash added to the chunks table. (In version one the API server receives the bytes and does both; with direct uploads, below, the work is split.)
  4. Commit. POST /files/commit with { path: "report.pdf", base_version: 7, chunks: [A, B, C′, D, E] }. The server makes version 8 real, in one Database transaction.
  5. Tell the other devices. The commit added a line to the change log; other devices hear about it and download only C′ and E (§08).

Step through it, then turn on connection drops to see the upload survive a dead network.

Saving a file: check → upload missing → commit
The client sketch on the left; what the client, the blob store and the metadata database hold on the right.
Code
1async function save(path, file, baseVersion) {
2 const chunks = cdcChunks(file); // §05
3 const hashes = chunks.map(sha256); // §06
4 for (;;) { // loop = resume after a drop
5 try {
6 const { missing } = await api.checkChunks(hashes);
7 for (const h of missing) {
8 await api.putChunk(h, chunks[hashes.indexOf(h)]);
9 }
10 return await api.commit({ path, baseVersion, hashes });
11 } catch (err) {
12 if (!isNetworkError(err) && !err.missingChunks) throw err; // 409 → §09
13 await sleepWithBackoff(); // then ask again (missing: §12)
14 }
15 }
16}
laptop · report.pdfchunks
A
B
C′ new
D
E new
blob store + chunks table
A
B
C
D
metadata · change log
report.pdf → v7 [A B C D]
📱 phone has v7
You edited report.pdf (version 7). The client cuts it into chunks and hashes each one, all locally. Chunks A, B and D are unchanged; C′ (your edit) and E (new text at the end) are new.
1 / 6

Why the commit must be atomic

Steps 1–3 can take minutes for a big file, and the network can die at any moment. That's fine, because nothing in steps 1–3 is visible to anyone. Uploaded chunks are just anonymous blobs; no file points at them yet. Only the commit changes what users see, and it changes everything at once.

Here's the commit as one database transaction. If any check fails, ROLLBACK undoes everything, and the old version stays current.

commit.sql
BEGIN;

-- 1. Lock the file's row, and check the client edited the newest version.
SELECT id, current_version FROM files
 WHERE namespace_id = 42 AND path = '/report.pdf'
   FOR UPDATE;                        -- ← other commits to this file now wait
-- app: if current_version = 8 and v8's chunks equal the request's chunks,
--      this is a retry of a commit that already worked: ROLLBACK, answer success.
-- app: else if current_version <> 7 (the client's base_version):
--      ROLLBACK and answer 409 Conflict (§09)

-- 2. Check every chunk in the manifest is really stored, and lock it (§12).
UPDATE chunks SET last_used = now() WHERE hash = ANY($1);  -- $1 = the 5 hashes
-- app: if fewer rows updated than distinct hashes: ROLLBACK, "upload these first"

-- 3. Write the new version and point the file at it.
INSERT INTO file_versions (file_id, version, size, chunks)
VALUES (1001, 8, 3145728, $1);
UPDATE files SET current_version = 8 WHERE id = 1001;

-- 4. Append to the change log with the namespace's next sequence number.
UPDATE namespaces SET last_seq = last_seq + 1 WHERE id = 42
RETURNING last_seq;                   -- say it returns 57
INSERT INTO changes (namespace_id, seq, file_id, version, op)
VALUES (42, 57, 1001, 8, 'put');

COMMIT;                               -- ← the moment version 8 exists

A brand-new file has no row to lock yet. Its commit INSERTs the files row with version 1 instead; if two devices create the same path at once, the second INSERT hits the UNIQUE (namespace_id, path) rule, and the server turns that error into the same 409 Conflict.

Because it's all one transaction, the ACID guarantees from Transactions apply. A crash before COMMIT leaves no trace: no half-written version, no change-log line pointing at nothing. The base_version check is a form of Optimistic concurrency: don't lock while the user edits for an hour; just refuse the commit if someone else got there first. (HTTP APIs often do the same with an ETag and 412; here it's a version number and 409.)

Resumable uploads

The chunk is already a natural unit of resume: after a drop, the client simply asks again which chunks are missing. The server's chunks table is the source of truth, so the client doesn't need to remember anything reliable about what it sent. That's a Resumable upload at chunk level.

With 4 MB chunks, losing a half-sent chunk wastes at most 4 MB. For bigger pieces, or very slow links, uploads can also resume within a chunk: the client opens an upload session, the server remembers how many bytes arrived, and after a drop the client asks "what offset do you have?" and continues from there. The open protocol tus works this way. The IETF is working on the same idea for plain HTTP: as of October 2026, "Resumable Uploads for HTTP" (draft-ietf-httpbis-resumable-upload) is still an Internet-Draft, not yet an RFC.

Downloading is the mirror image

When the phone learns that report.pdf is now version 8 (§08), it:

  1. Fetches the manifest: A B C′ D E.
  2. Compares it with its local chunk index, a small database of "which chunks I already have on disk, and where". It already has A, B and D from version 7.
  3. Downloads only C′ and E, and checks each one's hash.
  4. Writes the new file to a temporary file, then does an atomic rename over report.pdf. An app reading the file sees either the whole old version or the whole new one, never a half-written mix.
Practitioner§08

Hearing about changes: change log and cursors

In one sentenceEvery commit appends a numbered line to the namespace's change log. Each device remembers the last number it applied, its cursor, and asks "what changed after N?". A push from the server only says "go ask"; the asking is what moves data.

The change log

In §07 the commit wrote a row to the changes table with the namespace's next sequence number. Together those rows form the change log: an append-only list of everything that happened in one folder tree, in order.

seqchange
55put /notes.txt v3
56delete /old-draft.docx
57put /report.pdf v8

Because numbers only go up, a device needs just one number to know where it is: its cursor, "I've applied everything up to 55". To catch up, it asks for the rest:

list_changes.sql
SELECT seq, file_id, version, op
  FROM changes
 WHERE namespace_id = 42 AND seq > 55   -- ← "after my cursor"
 ORDER BY seq
 LIMIT 1000;                            -- page through big backlogs

Over HTTP that's GET /changes?cursor=55, answered with the changes plus the new cursor (57) and a has_more flag. The device applies each change (downloading chunks as in §07) and only then saves the new cursor. If it crashes halfway, it re-reads from the old cursor and re-applies a few changes, which is harmless because applying "put report.pdf v8" twice gives the same result. This is the same idea as cursor pagination in an API, and the same shape a chat app uses to catch up on missed messages.

A laptop that was closed for a week works exactly like one that was off for a second: it sends its old cursor and gets a longer list.

Nudging devices: don't make them poll

How does the phone know it's time to ask? It could ask every few seconds, plain Polling, but 3 million devices asking every 5 seconds is 600,000 requests a second, nearly all answering "nothing new". Two better options from WebSockets: choosing:

  • Long polling: the device asks "anything after 57?" and the server holds the request open until there is something, or 60 seconds pass. Works through any proxy or firewall.
  • A WebSocket or similar long-lived connection: the server pushes a message the moment a commit lands.

Either way, the message is only a nudge: "namespace 42 changed, go look". It carries no file data. The device then reads the change log as above.

Change log and cursors
The laptop saves files; the phone and tablet follow the log. Lose a nudge or take a device offline, and watch the cursor catch up.
change log · namespace 42newest #53
#52put /todo.md v4
#53put /notes.txt v2📱 phone cursor📟 tablet cursor
📱 phonecursor 53 · up to date
📟 tabletcursor 53 · up to date
requests to /changes so far: 0
Press “laptop saves a file”. The online phone is nudged and pulls; the offline tablet falls behind until it comes online.

Dropbox's public HTTP API exposes this exact shape, per its developer documentation: files/list_folder/continue takes a cursor and returns changes plus a new cursor, and files/list_folder/longpoll blocks until something changes after a cursor.

Practitioner§09

Conflicts: two devices edit offline

In one sentenceA conflict is two edits that started from the same version without knowing about each other. Spot it by comparing base versions, never clocks. Then don't throw either edit away unless the user asked you to.

How a conflict happens

Ana's shopping list is at version 1 on the server, her laptop and her phone. She takes the laptop on a train with no signal and adds "eggs". Meanwhile at home, her phone (online) gets "bread" added by her partner. Now:

  • The phone commits first: base version 1, server is at 1, so it's accepted as version 2.
  • The laptop reconnects and tries to commit "eggs" with base version 1. But the server is at 2.

That mismatch is the whole detection mechanism. The base version says "I started from 1"; the server says "1 is no longer the latest". The commit transaction from §07 refuses it with 409 Conflict, the HTTP status that means "your change clashes with the current state". No clocks involved: the server has a single, ordered history per file, and it simply compares two numbers.

Three ways to resolve it

  1. Last writer wins (LWW). Keep whichever edit has the later timestamp; drop the other. Simple and automatic, and it silently destroys an edit. (It's the same rule replicated databases use; see Replication: multi-leader.) With Last write wins, Ana's "eggs" or the "bread" vanishes and nobody is told.
  2. Keep both. Keep the server's version as the file, and save the other edit next to it as a conflicted copy: list (Ana's laptop's conflicted copy).txt. Nothing is lost; a person merges them later. This is the usual default for general files, because the sync service can't understand a PDF or a spreadsheet.
  3. Merge. For formats the system understands, combine the edits. Plain text can use a three-way merge: compare both versions with the common base (version 1). Lines only one side changed are taken from that side; if both changed the same line differently, it's a real conflict and you fall back to option 2. Apps that edit together live use CRDTs (also introduced in Replication: multi-leader), data structures designed so concurrent edits always merge.

Try all three. Take both devices offline, edit on each, and bring them back.

Conflict simulator
A shopping list synced to two devices. Take both offline, edit on each, then bring them back one at a time.
💻 laptoponline
base v1 · clock 09:00
  • ☐ milk
server · list.txtv1
  • ☐ milk
v1 · initial · 1 lines · 09:00
📱 phoneonline
base v1 · clock 09:00
  • ☐ milk
Each action moves the shared clock forward 7 minutes. LWW compares the edit times the devices report.
Try: laptop “go offline”, phone “go offline”, add an item on each, then bring the laptop online, then the phone. Switch strategy and repeat.

Why timestamps can't decide

LWW needs to know which edit was "last", and the only thing it has is each device's clock. Device clocks are wrong all the time: a laptop whose battery died resets to 2010, a phone set an hour ahead, a desktop that never syncs its time. This difference between machines' clocks is Clock skew. In the simulator above, push device B's clock ahead and its edits win every LWW contest, even ones it made earlier.

There's a deeper problem, explained in Clocks: vector clocks. Two offline edits don't have an order at all: neither device knew about the other's edit. They're concurrent. A timestamp forces a fake order onto them, and whichever edit loses the coin toss is gone. What you want to know is not "which came later?" but "did one of these edits know about the other?" The base version answers exactly that.

Two special cases

  • Edit vs delete. The laptop deleted notes.txt while the phone edited it. Most designs keep the edited file: losing an edit is worse than an unwanted file coming back, which the user can delete again.
  • Renames and moves. If a file is identified only by its path, "rename A to B" looks like "delete A, create B", and an edit to A on another device turns into a conflict with nothing. Giving every file a stable file ID (our files.id) and treating the path as a changeable attribute lets the system apply the rename and the edit together.
Staff-level§10

What breaks at 10× and 100×

In one sentenceVersion one is one API server and one database. As users grow, the parts break in a predictable order: bytes, then connections, then metadata, then the storage bill. Each fix is a known building block.

Drag the slider and watch boxes appear. The red box is the one that breaks next.

Architecture vs scale
Drag the slider. New boxes appear as users grow; the red box is what breaks at the next 10×.
peak commits ≈ 0.7/speak uploads ≈ 6 Mbit/sopen connections ≈ 3000
devices ×30k
chunk, hash, local index
1 API server⚠ next bottleneck
metadata and chunk bytes
Postgres
files, versions, chunks, changes
object store
one bucket
One API server, one Postgres, one bucket, and everything is nearly idle. Resist adding boxes: this design is easy to run and easy to reason about. It's red only because at 10× this one server would be copying every chunk's bytes and holding every device's open connection.
Server counts are rough illustrations, assuming the per-user numbers from §03 (20% daily active, 10 changes a day, 1 MB per change, 3 devices).

1. Bytes off the API servers

At first the API server receives chunk bytes and writes them to the object store. At a few Gbit/s of uploads (§03), API servers spend all their time copying bytes. Fix: hand the client a presigned URL and let it upload straight to the object store (§07). Behind a Load balancer (how load balancers work), the API servers are now small and stateless, so you add more as needed.

2. Connections get their own servers

3 million devices holding a long poll or a WebSocket open (§08) is a different job from answering short requests: it's mostly idle memory and open sockets. Put them on dedicated notification servers; Load Balancing: long-lived connections explains why they need different balancing from short requests. When a commit lands, the API publishes "namespace 42 changed" to a Publish/subscribe channel (a message broker, see Queues), and whichever notification servers hold connections for namespace 42 forward the nudge. See WebSockets: scaling for how that routing works and what happens when a server restarts (all its devices reconnect at once, so add Jitter to their retries).

3. Shard the metadata by namespace

At a few TB and hundreds of commits a second, one database becomes the bottleneck. Split it with sharding: pick a shard key so that each request touches one shard.

The namespace is the natural key. A commit touches the file row, its versions, the change log and the namespace's sequence counter, and all of them belong to one namespace. Put the whole namespace on one shard and the commit stays a simple, single-database transaction. "List my folder" and "changes since cursor N" also hit one shard.

The chunk index is different: it's keyed by hash, and hashes are spread evenly, so shard it by hash. The commit's "do these chunks exist?" check now asks other shards, but that's just a read of immutable facts (a chunk, once stored, stays until garbage collection, §12), so no cross-shard transaction is needed.

Reads that can be a little behind, like "changes since cursor N", can go to read replicas. A lagging replica just returns fewer changes this time; the cursor guarantees the device picks up the rest on its next read.

4. The storage bill

Past a few PB, money matters more than machines. Three levers:

  • Dedup (§06) and a sensible history policy: 30 days of versions cost 600 TB in our estimate; 365 days would cost about 7 PB, as much as all current files.
  • Erasure coding instead of full copies inside the blob store: survive as many lost disks or more for about 1.5× the space instead of 3× (6 + 3 survives any 3 lost pieces; 3 full copies survive 2).
  • Cold storage for old versions and files nobody opened in a year. Reads get slower, but most of them never happen.

5. Many regions

At 100× and beyond, users on other continents want low latency, and some countries require data to stay inside their borders. Give each user a home region where their metadata shard and chunks live. Cross-region dedup becomes a trade-off: either check another region's chunk index (slow) or store a second copy (costly). Most designs simply dedup within a region.

Staff-level§11

Failure modes: what if X dies?

In one sentenceFor each failure, ask two questions: what does the user see, and does any data get lost or silently changed? A good design is allowed to be slow or temporarily unavailable; it is never allowed to be wrong.

Pick a failure, then flip between a naive design and this design to see the difference.

Failure lab
Pick a failure, then compare a naive design with this one.
  1. 1Laptop uploads 3 of 5 new chunks. No commit yet.
  2. 2Battery dies.
  3. 3Server still points report.pdf at v7; the 3 chunks are unreferenced.
  4. 4Laptop restarts, asks “which are missing?”, uploads 2, commits v8.
What users see: Other devices keep seeing v7 until v8 is complete. Orphans are cleaned up by GC if the laptop never returns.

The four data failures, in words

A crash in the middle of an upload. The laptop dies after uploading 3 of 5 new chunks. Because only the commit makes a version visible (§07), the server still shows version 7 to everyone. The 3 uploaded chunks are orphans for now: when the laptop restarts, it asks "which are missing?", gets 2 hashes back, and finishes. If it never comes back, garbage collection (§12) removes the orphans later.

A corrupted chunk. A disk flips a bit, or a buggy client uploads wrong bytes. Content addressing catches both. On upload, the server recomputes the SHA-256 and rejects bytes that don't match their hash. On download, the client does the same hash check, and on a mismatch fetches the chunk again (the blob store has other copies). The blob store also does scrubbing: re-reading its disks in the background to catch rot before a user does.

A deleted file comes back to life. Ana deletes old.pdf on her laptop. Her tablet, offline for a month, still has it. If the server simply erased the file's row, the tablet's reconnect looks like "I have old.pdf, the server doesn't, so it must be new: upload it". The file is resurrected. The fix is a tombstone: the delete is recorded as a new version 9 with an empty manifest and size 0, the files row gets deleted = true, and the change log gets a delete line. The tablet reads "deleted in v9, based on v8", sees that it has v8 unchanged, and deletes its copy. (If it had edited v8 offline, that's the edit-vs-delete conflict from §09, and the edit is kept.)

Tombstones can't be kept forever. Keep them at least as long as change-log entries (§08); a device older than that does a full resync, which compares versions instead of trusting its own leftovers.

A client with its clock in the future. A desktop's clock says 2031. If the server ordered edits by device timestamps, that desktop would win every conflict for five years. This design never uses client clocks for ordering: versions come from the server's counter and conflicts from base_version (§09). Client timestamps are only shown to users, as "modified" dates.

When a component dies

DiesWhat users seeRecovery
One API serverSome requests fail and are retried on another serverStateless servers behind a load balancer; clients retry with Exponential backoff
Metadata database primaryCommits fail for that shard for under a minute; local edits queue upFailover to a replica. Use synchronous replication to at least one replica, so a "synced" commit is never lost
A notification serverIts devices stop getting nudges, then reconnect elsewhereChange log is the truth; devices also check every few minutes, so nothing is lost
The blob store (a region)Uploads and downloads of new content stall; browsing and local files still workClients keep files locally and retry; the store replicates across zones
The user's deviceNothing for othersUncommitted edits are on its disk; it resumes from its cursor when it returns
Staff-level§12

Running it: garbage collection and monitoring

In one sentenceWriting chunks is easy; deleting them is where file-sync systems lose data. And the most important thing to monitor isn't CPU: it's how long until a change reaches the other devices, and whether anything was lost.

Deleting chunks without deleting someone's file

When version history expires or a user empties their trash, some chunks are no longer listed in any manifest. They should be deleted, or storage grows forever. That's chunk garbage collection, and it's harder than it looks, because chunks are shared across versions, files and (with cross-user dedup) accounts.

Reference counting, keeping a count per chunk of how many manifests list it, sounds simple. In practice every commit must update counts on chunk-index shards other than its own, a missed decrement leaks a chunk forever, and an extra decrement deletes a chunk that's still in use. That last one is data loss.

Most designs use mark and sweep instead: periodically walk all live manifests and mark every chunk they list; then sweep (delete) the chunks that weren't marked. Marking billions of manifests takes hours, and uploads keep happening meanwhile. That opens races with the upload protocol, in three orders:

The GC race, in three orders
Chunk C is an old orphan. Pick who reaches it first, and how much protection the design has. The last option is this page's final design.
  1. 1GC's mark phase walks every manifest. Chunk C is an old orphan (no manifest has listed it for weeks), so C stays unmarked.
  2. 2Client: “which of A, C are missing?” → server: “only A”.
  3. 3Client uploads A, then commits v8 → [A, C]. C still exists, so the commit's check passes. v8 is live.
  4. 4Sweep reaches C, still working from the unmarked list it computed before v8 existed → DELETE.
Result : Data loss: v8 lists a chunk that no longer exists. Every device that downloads report.pdf v8 fails.

Order 1 is already safe: the commit re-checks that every chunk exists (§07), sees C is gone, and the client re-uploads it. Order 2 is the dangerous one. C is an old orphan: no manifest has listed it for weeks, so the mark phase leaves it unmarked. A client is told "we have C", commits a version that lists C, and only then does the sweep reach C, using a list of unmarked chunks computed before that commit existed. A grace period based on "how long ago was C last referenced" doesn't help: C really was unreferenced for weeks.

Order 3 is about the bytes, not the row. If the object store keeps C's bytes under the key chunks/C (the bare hash), then a client that re-uploads C after the sweep writes to the same key, and the sweep's delayed byte delete then removes the brand-new upload. With a soft delete of several days (below), that window is several days wide.

The fix, in two parts

Part 1: record when each chunk was last promised to someone, and re-check it at delete time. Part 2: never reuse an object key. Each upload of a chunk gets its own key, chunks/<hash>/<upload id>, and the row remembers which key holds the bytes (object_key in the §04 schema). Deleting an old key can then never hit a newer upload.

gc.sql
-- chunks.last_used: when a chunk was last promised to a client.
-- chunks.object_key: where its bytes are, e.g. 'chunks/9f2c…/u-7f3a' (§04).

-- /chunks/check: report which chunks exist, and touch them.
UPDATE chunks SET last_used = now()
 WHERE hash = ANY($1)
RETURNING hash;                     -- present; every other hash is missing

-- Commit, step 2 (§07): touch AND lock every chunk the new version lists.
UPDATE chunks SET last_used = now()
 WHERE hash = ANY($1);
-- app: fewer rows updated than distinct hashes → ROLLBACK, "upload these first"

-- Sweep, once per unmarked chunk.
-- $2 = when this mark phase started, minus a grace period (say 1 day)
DELETE FROM chunks
 WHERE hash = $1 AND last_used < $2 -- ← re-checked at delete time
RETURNING object_key;               -- a row back? queue THIS key for deletion in 7 days

Why that closes all three orders:

  • Touched by the check. Once a client is told "we have C", C's last_used is newer than the mark phase's start, so this sweep can't delete it. That covers the minutes the client spends uploading its other chunks.
  • Locked by the commit. The commit's UPDATE locks C's row until the transaction ends. If the sweep's DELETE arrives meanwhile, it waits. When the commit finishes, Postgres re-evaluates the WHERE on the updated row, finds a fresh last_used, and deletes nothing.
  • Deleted first? Then the commit's UPDATE touches fewer rows than it asked for, so it rolls back and the client re-uploads C (order 1).
  • Re-uploaded after the sweep? The new upload gets a new key, chunks/C/u-9b21, and a new row points at it. The delayed delete removes only the old key that the sweep returned, which no row points at any more (order 3).
  • Committed before the mark started? Then the mark phase sees the manifest and marks C.

We ran this on Postgres 16 with two sessions, including the shared-lock variant from the callout below (the sweep waited for it the same way). A commit held its transaction open for 2 seconds; the sweep's DELETE waited about 1.5 s for the lock, then reported DELETE 0. In the other order the sweep deleted C and got back its object_key, the commit's UPDATE touched only 1 of its 2 chunks and rolled back, and the re-upload inserted a new row with a different object_key.

Leftover bytes from abandoned uploads (uploaded, never confirmed, so no row) are cleaned up by a separate job driven by upload sessions, not by "no row points at it": for each session that expired more than a day ago without a confirm, it deletes that session's key. That's safe because keys are never reused and the API only confirms an upload while its session is open, an hour at most (§07), so such a key can't get a row any more. Driving it from sessions matters: "delete every object with no row" would also match the keys the sweep just queued and delete them early, defeating the 7-day safety net below.

What to monitor

Use the signals from Observability, but pick ones that match the requirements from §02:

MetricWhyAlert when
Sync latency: commit on device A → applied on device B, p99The user-visible promise "shows up in a few seconds"p99 above, say, 30 s for online devices
Commit error rate, split by reason (conflict, missing chunks, server error)Server errors are your bugs; conflicts are user behaviourServer errors above 0.1%; a sudden jump in "missing chunks" (a GC bug?)
Hash mismatches on upload and downloadCorruption, a buggy client, or an attackAny sustained increase above the baseline
Conflicted copies created per dayA spike means a client bug (for example a device that forgets its base version)Spike vs last week
Full resyncs per dayCursors expiring or local databases breakingSpike after a client release
Open notification connections and reconnect rateNotification servers' health, and reconnect stormsDrops of more than a few % at once
Storage: logical vs physical bytes, orphan bytes, GC deletesDedup ratio drift, leaks, and runaway deletesGC deleting far more than usual: stop GC first, investigate second
Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §05, Q2 in §06, Q3 in §07, Q4 in §08, Q5 in §09 and Q6 in §12.

QUESTION 1 / 6
One byte at the front

A 400 MB file is split into 4 MB chunks (100 chunks). You insert one byte at the very beginning. Roughly how many chunks change with fixed-size chunking, and with content-defined chunking?

QUESTION 2 / 6
Instant upload

A service dedups across all users and tells clients “skip this chunk, we have it”. What can an attacker learn?

QUESTION 3 / 6
Crash before commit

The client uploaded 4 of 5 missing chunks for report.pdf v8, then crashed before calling commit. What do other devices see?

QUESTION 4 / 6
Back from a week offline

A tablet's cursor is 120. The namespace's change log is now at 180, and all changes are still retained. What does the tablet do?

QUESTION 5 / 6
Spot the conflict

The server has notes.txt at version 5. The laptop sends a commit with base_version: 4, and its edit timestamp is later than version 5's. What should the server do?

QUESTION 6 / 6
Garbage collection race

GC deletes every chunk that no manifest references. Why can that delete data that's about to be used?

Answered 0 / 6
Foundations§14

Cheat sheet

🗂️
Split metadata from bytesFiles, versions and manifests in a relational database; immutable chunks in an object store, keyed by SHA-256.
✂️
Content-defined chunksA rolling hash picks cut points from the bytes, so an insert changes one chunk, not all the ones after it. Add min and max sizes.
🧬
Store each chunk onceDedup across versions and a user's files is free. Cross-user dedup saves more but leaks “someone has this file”.
📤
Check, upload, commitAsk which hashes are missing, upload those (resume by asking again), then commit in one transaction that checks the base version and the chunks.
📜
Change log + cursorEvery commit appends a numbered change. Devices pull “after cursor N”; pushes are only nudges.
⚔️
Conflicts by base versionBase ≠ current means two parallel edits. Keep both (conflicted copy) or merge; LWW loses data and trusts broken clocks.
🪦
Tombstones for deletesRecord deletes as versions, or an offline device brings files back from the dead.
🧹
Safe chunk GCMark and sweep. Check and commit touch last_used (the commit locks the rows); the sweep deletes only WHERE last_used < mark start − grace. Bytes live under never-reused keys, so the delayed (soft) delete can't hit a re-upload.
ConcernTypical choice
Chunk sizeHundreds of KB to a few MB (Dropbox's API documents 4 MB blocks)
Chunk nameSHA-256 of the bytes, verified on upload and download
Shard key for metadataNamespace (a user's tree or a shared folder): one commit = one shard
Shard key for the chunk indexThe chunk hash
Uploads at scalePresigned URLs straight to the object store
Notifying devicesLong polling or WebSockets on dedicated servers, carrying nudges only
Conflict defaultKeep both: a conflicted copy next to the file
Never trustClient clocks for ordering, pushes as the only source of changes, refcounts for deletes
WatchSync latency p99, commit errors by reason, hash mismatches, conflicts, resyncs, GC deletes
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in System DesignDesign a Payment System
Read →