Design Dropbox: System Design & Architecture

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.

Scope

In Scope

  • Store files reliably: upload, download, and organize files/folders
  • Sync files across a user's devices, including basic offline edits
  • Share files and folders with other users (view/edit permissions)
  • Handle large files efficiently with chunked, resumable uploads and block-level deduplication
  • Resolve sync conflicts with a simple, predictable strategy

Out of Scope

  • Collaborative real-time document editing (Google Docs style)
  • Full version history and rollback (excluded to keep focus on storage + sync)
  • File preview/viewer rendering
  • Enterprise security: virus scanning, DLP, compliance

💡 What does "offline edits" mean?

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.

Functional Requirements

  • A user can upload a file from any device
  • A user can download any of their files from any device
  • A user can share a file or folder with others and set view or edit permissions
  • A user's files stay in sync across all their devices
  • A user can organize files into folders (create, move, rename, delete)
  • A user can keep working offline; their changes sync automatically on reconnect
  • When the same file is changed in two places, the system resolves the conflict in a simple, predictable way
  • A user can upload very large files reliably, resuming if the connection drops

Non-functional Requirements

  • Availability: 99.99% (multi-region)
  • Durability: 99.999999999% (no data loss)
  • Consistency: read-your-own-writes for the uploading user; other devices converge within the sync window
  • Sync latency: changes appear on a user's other devices within ~30 seconds
  • API latency: metadata operations p99 < 200ms; file transfer is throughput-bound
  • Large files: support files up to 50GB

💡 What do the consistency and latency requirements mean?

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.

Back-of-the-Envelope Estimates

  • Assume 100M registered users, 50M daily active users across global infrastructure
  • Assume an average file size of ~500KB (most files are documents, photos, and small media)
  • A write is any operation that creates or changes data: uploading or editing a file, or renaming, moving, deleting, or re-sharing it
  • A read is any operation that fetches data without changing it: downloading a file, listing a folder, or polling for sync updates
  • File operations: 500M file uploads/day (writes), 2B file downloads/day (reads) via CDN
  • Synchronization: 1B sync operations/day across 200M connected devices (mostly reads that poll for changes)
  • Reads dominate writes: ~3B reads/day (2B downloads + ~1B sync polls) versus a few hundred million writes/day (uploads plus renames, moves, and shares), well over 5 reads for every write, so this is a strongly read-heavy system

💡 What the estimates mean, and how being read-heavy shapes our database choice

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.

High-level Architecture

  • API Gateway: Authenticates requests, applies per-user rate limiting, and routes to the right service behind a global load balancer
  • Metadata Service: The source of truth for what a user has - file and folder records (names, paths, sizes, permissions). Reads scale out across replicas and a cache
  • Upload/Download Service: Issues short-lived presigned URLs so clients transfer file bytes directly to and from blob storage, keeping large transfers off our servers
  • Blob Storage: Stores the actual file bytes as deduplicated, replicated ~4MB chunks (S3 style), fronted by a CDN for fast global downloads
  • Sync Service: Maintains an ordered change feed per user and answers "what changed since cursor X" so devices catch up with a small delta instead of rescanning. Devices poll this periodically; at very high device counts a lightweight "you have changes" notification channel keeps idle devices from polling constantly (see Critical Knowledge)
  • Metadata Database (sharded relational, read replicas): Stores file/folder/permission records, sharded by user so each user's tree stays on one shard
  • Cache (Redis): Absorbs the hottest metadata reads such as folder listings and per-device sync cursors, protecting the database

Data/User Flow Diagram

            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 files

💡 Two terms in the diagram: cursor and chunk

Cursor: 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.

Data Model & Storage

  • Users (PostgreSQL): user_id, email, storage_quota, subscription_plan, created_at, preferences. SQL because this is relational reference data with modest write volume and we want strong constraints.
  • Files (PostgreSQL, sharded by user_id): file_id, user_id, parent_folder_id, filename, size, content_hash, created_at, modified_at, deleted_at. We use a sharded relational store because metadata is relational (files live in folders, folders have permissions) and we need read-your-own-writes for the uploading user. Sharding by user keeps each user's tree on one shard so folder listings stay fast.
  • Folders (PostgreSQL, sharded by user_id): folder_id, user_id, parent_folder_id, name, created_at, modified_at. Same store and shard key as Files so a folder and its contents are colocated.
  • Permissions (PostgreSQL): resource_id, resource_type, user_id, permission_type, granted_by, expires_at. One dedicated table is the single source of truth for sharing - we deliberately do not also keep a shared_with array on folders, so the two cannot drift apart. Note that sharing crosses shards: since files are sharded by the owner, a file shared with you lives on the owner's shard, so a "shared with me" view looks up Permissions by your user_id and then fetches those rows from each owner's shard.
  • Chunk index (PostgreSQL/KV) + Blob Storage: file bytes are split into ~4MB chunks, each addressed by its hash. The index maps chunk_hash -> storage_location and a reference count, so identical chunks across files are stored once (block-level deduplication) and cleaned up safely.
  • Change feed (durable append-only log): an ordered list of per-user changes with a monotonic cursor, so a device can ask "what changed since cursor X." Durable on purpose, since it drives sync correctness.
  • Cache (Redis): hot metadata such as folder listings and the latest sync cursor per device. This is a cache over the durable stores above, so losing it never loses data.

💡 What is an append-only log?

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.

API Design

POST /v1/files/upload/init
Send the file and per-chunk hashes; get presigned URLs only for chunks the server does not already have (block-level dedup). Accepts an Idempotency-Key header so a retried request never creates a duplicate file.
POST /v1/files/upload/complete
Complete file upload after the missing chunks are transferred.
GET /v1/files/:id/download
Get a secure, presigned download URL for file content (supports HTTP Range for resumable downloads).
POST /v1/files/:id/share
Share file with users and generate sharing links. Accepts an Idempotency-Key header.
GET /v1/sync/changes?cursor=...
Get changes since an opaque cursor for device synchronization (cursor-based delta, paginated). Devices poll this periodically to pick up changes.
GET /v1/folders/:id/contents?cursor=...
List files and folders within a directory, paginated with a cursor for large folders.
PUT /v1/files/:id/metadata
Update file metadata like name and parent folder (rename/move).
DELETE /v1/files/:id
Delete file and move to trash with recovery option.
POST /v1/folders
Create new folder with specified name and location. Accepts an Idempotency-Key header.
GET /v1/users/storage
Get user storage quota and usage statistics.

Low-level Design

Upload/Download Service - upload flow (dedup + chunked, step by step):

  • 1) The client splits the file into 4MB chunks and hashes each one (SHA-256), plus a hash of the whole file.
  • 2) It calls POST /v1/files/upload/init with the file metadata, the whole-file content_hash, and the list of chunk_hashes.
  • 3) The service checks the Chunk Store: if the whole-file hash already exists it returns already_stored=true and the upload finishes instantly with no bytes sent; otherwise it returns presigned URLs only for the chunk_hashes it does not already have.
  • 4) The client uploads just those missing chunks directly to Blob Storage via the presigned URLs, in parallel, and can retry any single failed chunk because each is independent.
  • 5) The client calls POST /v1/files/upload/complete; the service confirms all chunks are present, writes the Files row and the File Chunks mapping, and increments ref_count for each chunk in the Chunk Store.
  • 6) It appends a "created" or "updated" row to the change feed so other devices pick it up on their next sync.

Upload/Download Service - download flow (step by step):

  • 1) The client calls GET /v1/files/:id/download.
  • 2) The service checks the caller has read permission, then returns a short-lived signed URL (or signed cookie) pointing at the CDN/Blob Storage plus an ETag. Because the content is private, the CDN edge validates that signature before serving, so a leaked URL stops working when it expires.
  • 3) The client fetches the bytes directly from the CDN, which serves popular files from the edge and only falls back to Blob Storage on a cache miss.
  • 4) Large downloads use HTTP Range requests, so a transfer that drops at 90% resumes from the last byte instead of restarting.
  • 5) The client compares the ETag/content_hash to skip re-downloading content it already has locally.

Metadata Service - folder listing (the read path):

  • A listing is "give me the files and folders whose parent_folder_id = X for this user."
  • It is served from the user's shard using the (user_id, parent_folder_id, filename) index, and hot results are cached in Redis so repeat loads skip the database entirely.
  • Reads spread across read replicas; only writes go to the primary, which is how we absorb the heavy read load.
  • Read-your-own-writes: right after a user changes something, that user's reads go to the primary (or a just-updated cache entry) so they always see their own change immediately, even though replicas may lag for a moment.

Metadata Service - rename, move, delete:

  • Rename and move are a single-row update to the Files/Folders row (filename or parent_folder_id) plus a change-feed append; no file bytes move.
  • Delete is a soft delete: we set deleted_at (trash) and append a "deleted" change, so other devices remove it locally while the user can still recover it.
  • Permanent deletion later decrements ref_count on each of the file's chunks; a chunk whose ref_count reaches 0 is safe to garbage-collect from Blob Storage.

Metadata Service - sharing and "shared with me" (the cross-shard read):

  • Sharing a file or folder writes a row to the Permissions table (resource_id, the recipient's user_id, and the permission_type) instead of touching the file row, so the grant lives in one place.
  • Files are sharded by the owner's user_id, so a file shared with you does not live on your shard - it lives on the owner's.
  • To build your "shared with me" view we look up Permissions by your user_id to get the list of resources shared with you, then fetch those file/folder rows from each owner's shard (a scatter read, usually a handful of shards, cached in Redis).
  • Permission checks on download work the same way: we confirm a Permissions row grants you read access before issuing the presigned URL.
  • This cross-shard step is the genuinely tricky part of the design, so it is worth naming out loud rather than assuming every read stays on one shard.

Sync Service - how a device catches up (step by step):

  • 1) Each device stores the cursor of the last change it has seen.
  • 2) The device polls GET /v1/sync/changes?cursor=N periodically.
  • 3) The service reads the change feed for that user where cursor > N (using the (user_id, cursor) index) and returns the ordered deltas plus a next_cursor, paginated with has_more for large backlogs.
  • 4) For each change the device downloads new or updated files (reusing the dedup download path) or removes deleted ones, then saves next_cursor.
  • 5) A device that was offline for a week catches up the same way - it just reads forward from its old cursor, so no special "full resync" path is needed.
  • Scale note: pure 30-second polling by every connected device would be millions of requests per second, far above the rest of the system. In practice a lightweight long-poll or push channel just signals "you have changes," so idle devices stay quiet and only call this endpoint when there is something to fetch. We keep plain polling here for simplicity and add the notification channel when poll volume becomes the bottleneck.

Sync Service - conflict resolution (last-writer-wins + conflicted copy):

  • A conflict happens when two devices edit the same file starting from the same base version, which is common after offline edits.
  • At upload/complete the service compares the file's current version with the version the client started from.
  • If they match, the write wins normally.
  • If they differ, we keep both: the latest write becomes the file and the losing edit is saved as a separate "filename (conflicted copy).ext".
  • The conflicted copy appears in the change feed as a normal "created" file, so every device sees it and no edit is silently lost.
  • This is deliberately simple with no merging, because collaborative editing is out of scope.

Supporting - rate limiting and quotas:

  • The API Gateway applies per-user rate limits (token bucket) on metadata and sync calls to protect the database.
  • Each user has a storage quota; upload/init returns 507 if the upload would exceed storage_quota.
  • File bytes ride presigned URLs + CDN, so heavy transfer traffic never competes with API capacity.

Supporting - multi-region durability and availability:

  • File chunks are replicated across regions in Blob Storage for 11 nines of durability.
  • The metadata database runs a primary with read replicas per shard; a replica is promoted if the primary fails.
  • The CDN serves reads from the nearest edge, which also shields the origin during regional issues.

Database Types and Schemas

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)

API Request/Response Payloads

POST /v1/files/upload/init
Request
{
  "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"
  ]
}
Response
{
  "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).
POST /v1/files/upload/complete
Request
{
  "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"}
  ]
}
Response
{
  "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"
}
GET /v1/files/:id/download
Response
{
  "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.
POST /v1/files/:id/share (file_id comes from the path)
Request
{
  "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"
}
Response
{
  "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
  }
}
GET /v1/sync/changes?cursor=84210
Response
{
  "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
}
Error responses (examples)
Response
// 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"
}

💡 Three terms from the upload flow: content hash, chunk hash, missing chunks

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.

Concluding Discussion

Potential Bottlenecks

  • Large file upload bandwidth limitations affecting user experience during peak hours
  • Metadata database query performance for users with millions of files causing sync delays
  • CDN cache miss rates during viral file sharing events increasing origin server load
  • Sync conflict resolution complexity affecting system performance during simultaneous edits
  • Blob storage PUT/GET rate limits during massive file migration or backup operations

Single Points of Failure

  • Centralized metadata database without proper sharding creating availability risks
  • Single-region blob storage without cross-region replication affecting disaster recovery
  • Monolithic sync engine without horizontal scaling limiting concurrent sync operations
  • Central authentication service affecting all file operations during service failures

Security Improvements

  • Encryption at rest with server-managed keys so cross-user block-level dedup still works; note that true end-to-end encryption with per-user keys would make identical files look different and disable cross-user dedup, which is a real tradeoff
  • Presigned URL security with time-limited access and IP address restrictions
  • Access control lists with granular permissions and audit logging
  • Data integrity verification using checksums and cryptographic hashing
  • Secure file sharing with password protection and expiration controls

Critical Knowledge

If the interviewer asks "why polling instead of real-time push?":

  • Polling is simple: each device just asks "what changed since my cursor?" on a timer, and the server stays stateless.
  • The tradeoff is load. If 200M devices each polled every 30s you would get millions of requests per second, far more than the rest of the system combined.
  • The common fix is a lightweight notification channel (a long-poll or push connection) that just says "you have changes, come sync now," so idle devices stay quiet and only sync when something actually changed.
  • For a first design, say you start with polling for simplicity and add the notification channel when poll volume becomes the bottleneck. Knowing the tradeoff matters more than picking one.

If the interviewer asks "how does deduplication save so much storage?":

  • Files are split into ~4MB chunks and each chunk is named by its content hash (SHA-256), so identical bytes always produce the same name.
  • If a chunk already exists, the client never uploads it again and we just point a new file at the existing chunk and bump its reference count. This is why re-uploading the same file, or a file many users share, costs almost no new storage.
  • A chunk is only deleted once its reference count hits 0, which keeps cleanup safe.
  • The catch: this dedup happens on the server across users, so it only works if the server can see the bytes. True end-to-end encryption with per-user keys would make identical files look different and break cross-user dedup. That is a real tradeoff, not a free win.

If the interviewer asks "why last-writer-wins instead of real merging?":

  • Merging two versions of an arbitrary file (a video, a spreadsheet, a zip) is not generally possible the way merging text is.
  • So we keep it simple: compare the version the client started from with the current version. If they match, the write wins. If they differ, we keep both and save the loser as "filename (conflicted copy).ext".
  • Nothing is ever silently lost, the user just sees two files and decides.
  • This is deliberately simpler than vector clocks or version vectors, which you only need if you are doing real-time collaborative editing, and that is out of scope here.

If the interviewer asks "why store file bytes and metadata separately?":

  • File bytes are large and rarely change, so they live in blob storage (S3 style) fronted by a CDN. The CDN serves most downloads from the edge so they never touch our servers.
  • Metadata (names, paths, sizes, permissions, sync cursors) is small but queried constantly, so it lives in a database with read replicas and a Redis cache in front.
  • Mixing the two would mean either a database full of huge blobs or an object store you cannot query. Splitting them lets each scale on its own axis: storage for bytes, query throughput for metadata.

If the interviewer asks "how does sharing work across a user-sharded database?":

  • Metadata is sharded by the owner's user_id, which keeps one user's whole folder tree on one shard so listings stay fast.
  • Sharing crosses shards: when user B opens a file user A shared, that file lives on A's shard.
  • The Permissions table is the source of truth. To build B's "shared with me" view we look up Permissions by B's user_id to find which resources are shared with them, then fetch those rows from each owner's shard.
  • This is the genuinely tricky part of the design, so call it out rather than pretending every read stays on one shard.