Design a Notion-like workspace (blocks and sync)
The interviewer asks
“Design a Notion-like workspace where everything is a block: pages contain text, lists, images and embedded databases, pages nest inside pages, and many people can edit the same page at once and see each other's changes in real time. Clients should load fast and keep working on flaky networks.”
What a strong answer looks like
A strong answer starts from the data model: every piece of content is a block with an id, a type, properties, a parent pointer and an ordered list of child ids, all stored in Postgres sharded by workspace id. Clients edit by sending small transactions of operations (set property, insert child after X, remove child) that are applied optimistically on the client, validated and versioned on the server, and then broadcast to everyone subscribed to that page over WebSockets. Clients keep a local cache (SQLite) and a queue of pending operations, which gives instant page loads and offline tolerance. The deep dives are the block tree and ordering, the sync and conflict model (operation-level last-writer-wins plus a CRDT or OT for rich text where it matters), permission inheritance down the page tree, and keeping search and databases (filtered views) fast via CDC into a search index.
Concepts to mention
1 Requirements
Functional
- Pages made of blocks: text, headings, to-dos, images, code, embeds; any block can have children
- Pages nest inside pages to any depth; move blocks and pages by drag and drop
- Real-time multi-user editing with presence (who is on the page, cursors)
- Databases: a page that holds rows (pages) with typed properties, shown as table/board/calendar views with filters and sorts
- Sharing and permissions per workspace, per page, inherited by sub-pages, plus public links
- Search across everything the user can access
- Page history and restore; trash with 30-day restore
- Fast loads from local cache and continued editing during short network drops
Non-functional
- 100M registered users, ~20M DAU, workspaces from 1 to 100,000 members
- Hundreds of billions of blocks in total
- Edits from one user visible to others on the same page in < 300 ms p95
- Cached page open < 200 ms; cold page open of ~500 blocks < 1 s p95
- No acknowledged edit is ever lost; durability via replicated Postgres and backups
- 99.9%+ availability; one huge workspace must not degrade others
- Search results < 500 ms p95 and reflect edits within ~1 minute
Out of scope (say it out loud)
- AI writing and Q&A features (mention as extension: RAG over permitted blocks)
- Billing and plans
- Third-party integrations and public API details
- Full offline-first mode for days of disconnected editing (discuss briefly)
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/api/v1/loadPageChunk | Load a page's blocks in chunks (first screen first), with each record's version |
POST/api/v1/syncRecordValues | Given record ids and the versions the client has cached, return only records that changed |
POST/api/v1/saveTransactions | Apply a batch of operations atomically: [{id, table, path, command: set|update|listAfter|listRemove, args}] with a transaction id for idempotency |
WS/realtime | Subscribe to page/record ids; receive 'record X is now version N' notifications and presence updates |
POST/api/v1/queryCollection | Run a database view: filters, sorts, grouping, pagination |
POST/api/v1/search | Full-text search, filtered to pages the caller can access |
POST/api/v1/files/upload-url | Pre-signed S3 URL for an image or file block |
4 Data model
blockPostgres (sharded by workspace_id)id (UUID, PK)workspace_idtypeproperties (JSONB: title as rich text, checked, language…)content (ordered array of child block ids)parent_idparent_tableversioncreated_bylast_edited_by/ataliveThe core table. workspace_id is the shard key so a whole page tree lives on one shard and transactions stay local. content order is the source of truth for child order; parent_id is the back pointer for permission and breadcrumb walks.
collection (database) + viewsPostgrescollection idworkspace_idparent blockschema (property id → name, type, options); view: idtype (table/board/calendar)filtersortgroup_byDatabase rows are ordinary page blocks whose parent is the collection; their properties hold the typed values.
permissionsPostgresblock_id (pages only)principal (user/group/workspace/public)role (full/edit/comment/view)Stored only on pages where sharing was changed; everything else inherits by walking parent_id up to the nearest page with explicit permissions.
transaction_logPostgres (same shard) → CDC to Kafkatransaction_id (client-generated UUID, unique)user_idoperationsapplied_atrecord versions afterIdempotency for retried saves, audit and page history. CDC from Postgres feeds search, history snapshots and analytics.
page_snapshotsS3 + Postgres indexpage_idsnapshot_atblob key (serialized block tree)Taken every ~10 minutes of activity for version history; restore rewrites blocks from a snapshot.
search_indexElasticsearch / OpenSearchblock_idpage_idworkspace_idtexttitleancestorspermission principalslast_editedIndexed asynchronously from CDC. Routing by workspace_id keeps a workspace's documents on few shards.
client cacheSQLite on device (browser via WASM/OPFS, desktop, mobile)records with versionspending transaction queueInstant page opens and offline tolerance; reconciled with syncRecordValues.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
User types in a paragraph while a colleague watches
- 1The editor turns keystrokes into an operation: set block 7f3…'s properties.title to the new rich text. Typing is batched every ~300-500 ms.
- 2The client applies it to its local SQLite cache immediately (optimistic), adds it to the pending queue, and sends saveTransactions with a client-generated transaction id.
- 3The block API checks the transaction id has not been applied before (idempotent retry), and asks the permission service whether the user can edit the page.
- 4In one Postgres transaction on the workspace's shard, it applies the operations, bumps each touched record's version, and writes the transaction log row.
- 5After commit it publishes 'block 7f3… → version 42' to the page's Redis pub/sub channel and acks the client, which removes the op from its pending queue.
- 6The WebSocket gateway nodes holding subscribers for that page push the notification; the colleague's client sees its cached version is 41 and fetches the new record (or receives the operation inline).
- 7The colleague's editor merges the new text, keeping their cursor position stable.
Open a large page
- 1The client renders instantly from SQLite if it has the page cached.
- 2In the background it calls syncRecordValues with cached record versions; the server returns only records that changed.
- 3On a cold open it calls loadPageChunk: the server fetches the page block and walks content ids breadth-first, returning the first ~100 visible blocks plus their ids for later chunks.
- 4The client subscribes to the page over WebSocket so later changes arrive as pushes.
- 5Remaining chunks and images (via CDN) load as the user scrolls.
Move a sub-page to another parent
- 1The client sends one transaction: listRemove the page id from old parent's content, listAfter it into the new parent's content, set the page's parent_id.
- 2The server checks edit rights on the page, old parent and new parent, then applies all three operations atomically.
- 3Because permissions are inherited, the effective access of the moved subtree changes; the permission cache entries for that subtree are invalidated.
- 4A CDC event reindexes the subtree's ancestor and permission fields in search, asynchronously.
7 Deep dives
The block model and ordering
Everything is a block: a page is a block of type page whose content array lists child block ids in order, and each child points back with parent_id. This one uniform shape makes nesting, drag and drop, and turning a to-do into a heading (change type, keep properties) trivial. The content array is the source of truth for order, which makes reordering a single operation on one parent row: listRemove(id) then listAfter(id, after=X). Using 'insert after X' instead of 'insert at index 3' matters for concurrency: if two people insert at the same time, positions computed by index shift and collide, while 'after X' still lands in a sensible place. An alternative is fractional indexing (each child has a sortable position key like 'a0', 'a0V' between neighbours), which avoids rewriting a big array but needs occasional rebalancing. Keep the parent_id pointer even though it duplicates information, because permission checks, breadcrumbs and 'move' validation need to walk up the tree. Guard against cycles on move (a page cannot be moved inside its own descendant) by walking up from the new parent before applying.
Sync and conflict resolution
Each record has a version that increases on every change. Clients apply their own operations optimistically, keep them in a pending queue, and send them as idempotent transactions. The server is the single ordering point per workspace shard: it applies transactions in the order they commit and bumps versions. For most fields (checked, type, a database property, the content array) operation-level last-writer-wins is fine: operations are small and target one property, so two people editing different blocks never conflict, and list operations like listAfter merge naturally. Rich text inside one block is where real conflicts happen: two people typing in the same paragraph. Whole-value last-writer-wins would erase one person's words. The fix is to represent the block's text with a text CRDT (Yjs or Automerge style) or OT, so concurrent inserts both survive; this also enables longer offline editing because CRDT merges do not need a central server to transform operations. When the client receives a newer version from the server for a record it has pending ops on, it rebases: applies the server state and replays its pending ops on top. Presence (cursors, who is viewing) is ephemeral and goes through pub/sub only, never the database.
Sharding by workspace and the huge-workspace problem
Shard Postgres by workspace_id: all blocks of a workspace live on one shard, so a transaction that moves a page or edits several blocks is a local ACID transaction, and loading a page never crosses shards. Notion publicly described this approach: 480 logical shards mapped onto a smaller number of physical Postgres hosts, so capacity can be added by moving logical shards rather than re-hashing data. Use a routing table (workspace_id → logical shard → physical host) in the application or a proxy like PgBouncer. The risk is a giant workspace: a 100,000-employee company produces far more blocks and traffic than average and can overload its host. Mitigations: place the largest workspaces on dedicated physical hosts, add read replicas for page loads, cache hot pages in Redis, and rate-limit bulk operations like imports per workspace. Keep cross-workspace features (search across your workspaces, user profile) in separate services so they never need cross-shard joins.
Permissions that inherit down a tree
Permissions are stored only where sharing was explicitly set (workspace root, a few shared pages); every other block inherits from the nearest ancestor with explicit permissions. A naive check walks parent_id pointers up to the root on every request, which is many queries for deep pages. Two optimisations: cache each block's 'permission root' (the nearest ancestor with explicit rules) in Redis or a denormalised column, and cache the computed role per (user, permission root). Invalidate when a page moves or its sharing changes; moves are rare compared to edits, so invalidation cost is acceptable. Search is the hard case: you cannot check permissions for 10,000 hits one by one. Index each document with the principals that can read its permission root (users, groups, workspace) and filter in Elasticsearch by the caller's principals, then do a final exact check on the top 20 results to catch stale index entries. Public links get a separate principal ('anyone with link') so they can be revoked instantly.
8 Trade-offs
Postgres sharded by workspace vs a NoSQL store
Blocks have relationships, need multi-row atomic updates (move = edit two parents and the child) and benefit from SQL for database views. Sharded Postgres keeps those transactions local to one shard. A wide-column store would scale writes more easily but push atomicity and querying into application code.
Operation-level LWW vs CRDT for everything
LWW on small operations is simple, cheap and correct for most fields. CRDTs give true concurrent text editing and offline merges but add metadata and memory overhead. A hybrid is common: CRDT or OT for text inside a block, plain versioned operations for structure and properties.
Notify-then-fetch vs pushing full operations
Sending only 'record X is now version N' keeps the WebSocket tier simple and lets the client fetch through the normal permission-checked API. Pushing full operations saves a round trip and is better for fast typing. Many systems push operations for small changes and fall back to notify-then-fetch for large ones.
Client-side cache vs always-fresh loads
A local SQLite cache makes page opens instant and survives flaky networks, at the cost of a sync protocol and cache invalidation complexity. Without it, every page open costs a server round trip and many block reads, which is noticeably slower on mobile networks.
9 Bottlenecks
A page with thousands of concurrent viewers
An all-hands page viewed by 5,000 people turns every edit into 5,000 pushes. Use per-page channels with WebSocket gateways subscribing once per node (not per user), coalesce updates every ~100 ms, and serve the record fetch from a Redis cache instead of Postgres.
Huge pages and deep databases
A page with 20,000 blocks or a database with 500,000 rows is slow to load and query. Load pages in chunks, virtualise rendering, and index database property values (a properties table or generated columns with indexes) for filtered views; very large databases can be served by a separate query engine.
Shard hotspots
One workspace running a bulk import can saturate its shard. Rate-limit bulk writes per workspace, run imports as background jobs with batching, and move big tenants to dedicated hosts.
10 Failure handling
Client loses network mid-edit
Edits stay in the local pending queue and the editor keeps working. On reconnect the client retries pending transactions (idempotent by transaction id), fetches newer versions and rebases its pending operations on top.
Realtime tier fails
Saves still work through the HTTP API; users simply stop seeing others' changes live. Clients detect the dropped socket, fall back to polling syncRecordValues every few seconds, and resubscribe when the gateway recovers. No data is lost because realtime carries notifications, not the source of truth.
A Postgres shard primary fails
Fail over to a synchronous or semi-synchronous replica in another zone (tens of seconds). Workspaces on that shard see save errors briefly; their clients queue edits locally and retry. Other shards are unaffected.
Search index lags
If the CDC consumer falls behind, new pages are briefly missing from search but the editor is unaffected. Alert on consumer lag and replay from Kafka offsets; the index can always be rebuilt from Postgres.
11 What impresses
- Starts from the block data model and shows how one uniform shape handles pages, nesting and databases
- Explains why 'insert after id' beats 'insert at index' under concurrency
- Uses operation-level LWW for structure and a CRDT only where text conflicts really happen
- Chooses workspace as the shard key and discusses the giant-tenant problem
- Solves permission inheritance for both page loads and search
- Makes realtime carry notifications, so its failure degrades freshness but never loses data
12 Mistakes
- Storing a whole page as one big document blob, so two editors overwrite each other
- Using whole-value last-writer-wins for paragraph text
- Sharding by block id, which scatters a page across shards and breaks atomic moves
- Checking permissions for every search hit one at a time
- Writing presence and cursor updates to the database
- No idempotency on save retries, causing duplicated blocks
13 Follow-ups
Lessons this design uses
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.