Build a cloud-based file storage and synchronization platform that allows users to upload, download, share, and sync files across multiple devices with high availability and reliability.
Offline edits are changes a user makes to a file while their device has no internet connection: editing, renaming, moving, or deleting a file with the app disconnected. The client saves those changes locally, then syncs them to the server once it reconnects. This matters because two devices can edit the same file while both are offline, and when they reconnect the system has to reconcile the differences. That reconciliation is exactly the "sync conflict resolution" we list in scope.
Read-your-own-writes means that right after you upload or change a file, you see your own change immediately on the device you made it on, with no waiting. "Other devices converge within the sync window" means your other devices catch up a little later (within the ~30 second sync window) rather than instantly, which is fine for file storage. On latency, metadata operations are the small actions like listing a folder, renaming a file, or checking permissions; "p99 < 200ms" means 99% of those finish in under 200 milliseconds so the app feels snappy. "File transfer is throughput-bound" means the time to upload or download a file depends on its size and the network speed, not a fixed target: a 10GB file will always take longer than a 1KB file, so we measure transfer by bandwidth rather than a flat latency number. On CAP theorem, remember that during a network partition you can only keep two of consistency, availability, and partition tolerance. For file storage and sync we choose availability and partition tolerance (an AP system): users should still be able to open and edit their files even when parts of the system cannot talk to each other, which is exactly what makes offline edits possible. The tradeoff is that we give up strong consistency, so other devices may briefly show a stale version and then catch up, and that is why we need a conflict resolution strategy when changes collide.
Because reads outnumber writes by several times over, we design for fast, cheap reads at scale. First, split the data by type. File content (the actual bytes) is large and rarely changes: most files, like photos and PDFs, are uploaded once and then downloaded or synced many times but never edited again, and even editable documents only produce a new saved version now and then, not on every keystroke. Each saved version is also immutable - a change creates a new version with a new content hash - which is exactly what lets a CDN cache it safely without serving stale data. So file content lives in object/blob storage (S3 style) fronted by a CDN, not in a database, and the CDN absorbs most download reads before they ever reach our servers. File metadata (names, paths, sizes, permissions, sync state) is small but read and updated constantly, so it lives in an actual database. It is touched on almost every action that is not a raw byte transfer: opening a folder lists its files (a metadata read), every sync poll checks the metadata for what changed, and sharing or downloading a file checks its permissions first. These happen far more often than the bytes themselves change, which is why metadata needs a fast, queryable store with replicas and a cache. For that metadata database, a read-heavy pattern points to a store that scales reads with read replicas, plus a cache like Redis in front to absorb the hottest lookups such as folder listings and sync polls. Writes go to the primary, and the many reads spread across replicas and cache. Metadata is relational (folders contain files, files have permissions) and we need read-your-own-writes for the uploading user, so a sharded relational database such as MySQL or Postgres, sharded by user, is a solid and simple default. Sharding by user keeps each user's files and folder tree on one shard, which keeps the common listing queries fast. A few terms from the estimates, in plain language: "Via CDN": a download is a read because it only fetches bytes and changes nothing. A CDN (content delivery network) is a set of cache servers spread around the world, close to users. When a file gets downloaded a lot, the CDN keeps a copy near the user and serves it from there, so the download is faster and the request never reaches our own servers; less popular files miss the cache and fall back to blob storage. A "miss" means the edge has no copy because the file is rarely requested (or its cached copy has expired), so the CDN fetches the bytes from our blob storage (the origin), hands them to the user, and usually keeps a copy so the next request for that file is fast. "Mostly reads that poll for changes": each device checks in on a timer and asks "has anything changed since I last looked?" Most of the time the answer is "nothing new," so the device just gets an empty reply. That check only fetches information and changes nothing, so it counts as a read, not a write. Because every connected device does it over and over, these polls add up to a large share of all reads even though most of them find nothing. Why typing in a document is not many writes: a write means saving a new version of a file to the server, not each keystroke. While you type, the changes stay on your own device; only when the file is saved and synced does one new version get uploaded, and that is one write. So an hour of editing might be just a few writes (one per save), not thousands. Per-keystroke real-time editing like Google Docs is a separate system and is out of scope here.
CLIENT (desktop / mobile / web)
|
[ API Gateway ] (auth; per-user rate limiting)
|
+-------------------+------------------+
| | |
[Metadata [Upload/Download [Sync
Service] Service] Service]
| | |
| presigned URL | change feed
v v v
[Metadata DB] [Blob Storage] -----> [CDN] --> downloads
(sharded SQL (chunks, dedup,
+ replicas) replicated)
^
|
[Cache (Redis)]
(hot listings, sync cursors)
Upload: client -> Metadata Service (create record) -> presigned URLs ->
client uploads chunks directly to Blob Storage -> complete ->
Sync Service appends change to the feed
Download: client -> Metadata Service -> CDN / presigned URL -> client pulls bytes
Sync: device polls Sync Service for changes since its cursor ->
download new/changed filesCursor: a bookmark into the change feed. It is an ever-increasing number marking the last change a device has already seen, so the device can ask "what changed since cursor N?" and get only the new updates instead of re-scanning everything. Chunk: a fixed-size piece of a file, about 4MB here. We split every file into chunks so large uploads can run in parallel, resume after a dropped connection, and reuse identical pieces across files (deduplication) instead of moving the whole file each time.
An append-only log is any store where you only ever add new entries to the end. You never update or delete what is already there, so each entry is a permanent record of "this happened." New entries get an ever-increasing number (the cursor), which gives a strict order of events. There are two common ways to build one. The first is an ordinary database table. In this design the Change Feed is a PostgreSQL table whose primary key is an auto-incrementing number (a BIGSERIAL column) that serves as the cursor; every change is a single INSERT and we never UPDATE or delete existing rows. MySQL or any relational database works the same way. We use a plain table here because the metadata database is already relational and the query "give me every row where cursor > N" is a simple, fast, indexed lookup. The second is a stream. A stream is an append-only sequence of events managed by a dedicated log or messaging system instead of a regular database: producers append events to the end, and any number of readers consume them forward at their own pace, each remembering its own position (usually called an offset). Streams are built for very high append rates and many simultaneous readers. The common technologies are Apache Kafka and AWS Kinesis, which are purpose-built streaming logs you run as their own service; a related option is change-data-capture such as DynamoDB Streams, where the database itself automatically emits an ordered feed of every change to a table so a consumer can read it forward. You would reach for a stream over a plain table once the change volume and number of subscribers grow large enough that a single database table becomes the bottleneck. For a first design, the Postgres table is simpler and perfectly sufficient. That shape is perfect for our change feed. When a file is created, updated, or deleted, we append one entry describing the change. A device remembers the cursor of the last change it saw, and to sync it simply asks for everything after that cursor. Because entries are never moved or rewritten, two devices reading the same log always see the same history in the same order, and a device that was offline for a week can catch up just by reading forward from where it left off. It also keeps writes fast and simple: appending to the end avoids the locking and contention you would get from many devices updating shared rows, and the log doubles as an audit trail of what changed and when.
Database Schemas
- Users (PostgreSQL)
user_id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
storage_quota BIGINT DEFAULT 2147483648, -- 2GB
storage_used BIGINT DEFAULT 0,
subscription_plan subscription_enum DEFAULT 'free',
created_at TIMESTAMP DEFAULT NOW(),
last_login TIMESTAMP,
preferences JSONB, -- sync settings, notifications
INDEX idx_user_email ON (email)
INDEX idx_subscription ON (subscription_plan, created_at)
- Files (PostgreSQL, sharded by user_id)
file_id UUID PRIMARY KEY,
user_id UUID NOT NULL,
parent_folder_id UUID, -- NULL for root
filename VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
content_type VARCHAR(100),
content_hash VARCHAR(64) NOT NULL, -- SHA-256 of the whole file
chunk_count INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
modified_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP, -- soft delete (trash)
INDEX idx_files_listing ON (user_id, parent_folder_id, filename) -- folder listings
INDEX idx_files_sync ON (user_id, modified_at) -- delta sync
- Folders (PostgreSQL, sharded by user_id)
folder_id UUID PRIMARY KEY,
user_id UUID NOT NULL,
parent_folder_id UUID, -- NULL for root
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL,
modified_at TIMESTAMP NOT NULL,
INDEX idx_folders_listing ON (user_id, parent_folder_id, name)
-- Note: sharing lives only in the Permissions table, not a shared_with[] column here
- File Chunks (PostgreSQL) -- maps a file to its ordered chunk hashes
file_id UUID NOT NULL,
chunk_number INTEGER NOT NULL,
chunk_hash VARCHAR(64) NOT NULL, -- SHA-256 of this chunk
chunk_size BIGINT NOT NULL,
PRIMARY KEY (file_id, chunk_number)
- Chunk Store (PostgreSQL/KV) -- one row per UNIQUE chunk, enables block-level dedup
chunk_hash VARCHAR(64) PRIMARY KEY, -- content-addressed
storage_key VARCHAR(500) NOT NULL, -- where the bytes live in blob storage
size BIGINT NOT NULL,
ref_count BIGINT NOT NULL DEFAULT 0 -- how many files reference this chunk
-- identical chunks across users/files are stored once; cleaned up when ref_count hits 0
- Change Feed (PostgreSQL, durable append-only; NOT a cache)
cursor BIGSERIAL PRIMARY KEY, -- monotonic per user
user_id UUID NOT NULL,
resource_type resource_type_enum NOT NULL, -- 'file', 'folder'
resource_id UUID NOT NULL,
action change_action_enum NOT NULL, -- 'created', 'updated', 'deleted'
created_at TIMESTAMP NOT NULL,
INDEX idx_changefeed ON (user_id, cursor) -- "what changed since cursor X"
- Sync Cursor Cache (Redis) -- cache only; source of truth is the Change Feed
Key: "sync:{user_id}:{device_id}"
Value: { "last_cursor": 84213 }
-- if Redis is lost, we re-read the device's cursor from durable storage; no data loss
- Permissions (PostgreSQL)
permission_id UUID PRIMARY KEY,
resource_id UUID NOT NULL, -- file_id or folder_id
resource_type resource_type_enum NOT NULL, -- 'file', 'folder'
user_id UUID NOT NULL,
permission_type permission_enum NOT NULL, -- 'read', 'write', 'admin'
granted_by UUID NOT NULL,
granted_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
INDEX idx_perms_resource ON (resource_id, user_id)
INDEX idx_perms_user ON (user_id, granted_at){
"filename": "presentation.pptx",
"file_size": 52428800,
"content_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"parent_folder_id": "folder_8f3e2d1c9a7b",
"content_hash": "sha256:7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730",
"chunk_hashes": [
"sha256:aaaa...0001",
"sha256:bbbb...0002",
"sha256:cccc...0003"
]
}{
"file_id": "file_4c8e6f2a1b9d",
"upload_session_id": "upload_7a2b8f4e9c1d",
"chunk_size": 4194304,
"chunk_count": 13,
"already_stored": false,
"missing_chunks": [
{
"chunk_number": 3,
"chunk_hash": "sha256:cccc...0003",
"presigned_url": "https://s3.amazonaws.com/dropbox-storage/chunks/...",
"expires_at": "2025-01-15T11:30:00Z"
}
],
"parallel_uploads": 3,
"expires_at": "2025-01-15T12:30:00Z"
}
// The client sends every chunk hash; the server returns presigned URLs ONLY for
// chunks it does not already have (block-level dedup), so unchanged chunks are
// never re-uploaded. If the whole-file content_hash already exists, the server
// returns "already_stored": true with an empty missing_chunks list (instant upload).{
"upload_session_id": "upload_7a2b8f4e9c1d",
"file_id": "file_4c8e6f2a1b9d",
"chunks": [
{"chunk_number": 1, "chunk_hash": "sha256:aaaa...0001"},
{"chunk_number": 2, "chunk_hash": "sha256:bbbb...0002"}
]
}{
"file_id": "file_4c8e6f2a1b9d",
"status": "upload_complete",
"file_url": "https://cdn.dropbox.com/files/file_4c8e6f2a1b9d",
"metadata": {
"filename": "presentation.pptx",
"file_size": 52428800,
"created_at": "2025-01-15T10:30:00Z",
"content_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
},
"sharing_url": "https://dropbox.com/s/abc123/presentation.pptx"
}{
"file_id": "file_4c8e6f2a1b9d",
"download_url": "https://cdn.dropbox.com/files/file_4c8e6f2a1b9d",
"expires_at": "2025-01-15T11:30:00Z",
"metadata": {
"filename": "presentation.pptx",
"file_size": 52428800,
"content_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"last_modified": "2025-01-15T10:30:00Z"
},
"caching_headers": {
"etag": "7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730",
"cache_control": "private, max-age=3600"
}
}
// The download_url is a presigned URL that supports HTTP Range requests, so a
// large download that drops at 90% resumes from the last byte instead of restarting.{
"recipients": [
{
"email": "colleague@company.com",
"permission": "read"
},
{
"user_id": "user_9d4f1a2b8e6c",
"permission": "write"
}
],
"expires_at": "2025-02-15T10:30:00Z",
"allow_download": true,
"password": "optional-link-password"
}{
"share_id": "share_2a8f6c4e1b9d",
"sharing_url": "https://dropbox.com/s/abc123xyz/presentation.pptx",
"recipients": [
{
"email": "colleague@company.com",
"permission": "read",
"status": "pending",
"invited_at": "2025-01-15T10:30:00Z"
},
{
"user_id": "user_9d4f1a2b8e6c",
"permission": "write",
"status": "pending",
"invited_at": "2025-01-15T10:30:00Z"
}
],
"share_settings": {
"expires_at": "2025-02-15T10:30:00Z",
"allow_download": true,
"password_protected": true
}
}{
"since_cursor": 84210,
"next_cursor": 84213,
"changes": [
{
"cursor": 84211,
"resource_type": "file",
"resource_id": "file_4c8e6f2a1b9d",
"action": "created",
"metadata": {
"filename": "presentation.pptx",
"parent_folder_id": "folder_8f3e2d1c9a7b",
"file_size": 52428800
}
},
{
"cursor": 84212,
"resource_type": "file",
"resource_id": "file_xyz789",
"action": "deleted"
}
],
"has_more": false
}// 507 Insufficient Storage - upload would exceed the user's quota
{
"error": "quota_exceeded",
"message": "Upload would exceed your storage quota",
"storage_used": 2147483648,
"storage_quota": 2147483648
}
// 404 Not Found - file does not exist or the caller has no access to it
{
"error": "not_found",
"message": "File not found"
}
// 409 Conflict - the file changed on the server since the client last synced
{
"error": "conflict",
"message": "File was modified by another device; re-sync and retry"
}Content hash: a fingerprint (SHA-256) of the whole file's bytes. The same bytes always produce the same hash, so the server can tell two files are identical just by comparing hashes, without ever looking at the contents. For example, if a coworker already uploaded the exact same 50MB onboarding PDF you are now uploading, the server sees that hash already exists, stores nothing new, and just points your file at the copy it already has - your upload finishes instantly even though no bytes left your device. Chunk hash: the same kind of fingerprint, but of a single ~4MB chunk rather than the whole file. It lets the server check each piece of the file individually instead of all-or-nothing. Missing chunks: when the client sends its list of chunk hashes, the server compares them against the chunks it already stores and replies with only the hashes it does not have - those are the "missing chunks." The client then uploads just those pieces, so any chunk the server has already seen (from this file, an earlier version, or another user) is never re-sent. This is what makes block-level deduplication work.