Design problems
Messaging & collaborationHardSDE-2 35 min

Design a Notion-like workspace (blocks and sync)

Asked at
AtlassianFreshworksGoogleMicrosoftZoho

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

Everything is a block with parent pointer and ordered childrenInsert-after operations instead of index positionsTransactions of small operations with record versionsOptimistic local apply with a pending queueIdempotent transaction idsWebSocket subscriptions per page and presenceCRDT or OT for concurrent text inside a blockShard Postgres by workspace idInherited permissions with a cached permission rootCDC to Elasticsearch with permission filteringLocal SQLite cache for instant loads

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

Write transactions~50k/s avg, ~150k/s peak20M DAU × ~200 transactions/day (typing is batched into ~1 transaction every 0.5-1 s per active editor) = 4B/day ÷ 86,400 ≈ 46k/s; ×3 for weekday peak
Page loads~10k/s avg, ~30k/s peak20M DAU × ~40 page opens/day = 800M/day ÷ 86,400 ≈ 9k/s; many are served from the client cache with only a version check
Block reads~2-5M block rows/s at peak~30k cold-ish loads/s × 100-200 blocks fetched (visible first chunk)
Block storage~200-300 TB in Postgres (before replicas)~200B blocks × ~1-1.5 KB per row (properties JSON, content id array, metadata)
FilesPetabytes in S3Images and uploads live in object storage behind a CDN, referenced by blocks
Realtime connections~5M concurrent WebSockets at peak~25% of DAU online at once, one socket per client
Change fan-outsmall for most pagesMost pages have 1-3 viewers; a company-wide wiki page may have 5,000 viewers, so fan-out must handle both

3 API

EndpointWhat it does
POST/api/v1/loadPageChunkLoad a page's blocks in chunks (first screen first), with each record's version
POST/api/v1/syncRecordValuesGiven record ids and the versions the client has cached, return only records that changed
POST/api/v1/saveTransactionsApply a batch of operations atomically: [{id, table, path, command: set|update|listAfter|listRemove, args}] with a transaction id for idempotency
WS/realtimeSubscribe to page/record ids; receive 'record X is now version N' notifications and presence updates
POST/api/v1/queryCollectionRun a database view: filters, sorts, grouping, pagination
POST/api/v1/searchFull-text search, filtered to pages the caller can access
POST/api/v1/files/upload-urlPre-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/atalive

The 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) + viewsPostgres
collection idworkspace_idparent blockschema (property id → name, type, options); view: idtype (table/board/calendar)filtersortgroup_by

Database rows are ordinary page blocks whose parent is the collection; their properties hold the typed values.

permissionsPostgres
block_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 Kafka
transaction_id (client-generated UUID, unique)user_idoperationsapplied_atrecord versions after

Idempotency for retried saves, audit and page history. CDC from Postgres feeds search, history snapshots and analytics.

page_snapshotsS3 + Postgres index
page_idsnapshot_atblob key (serialized block tree)

Taken every ~10 minutes of activity for version history; restore rewrites blocks from a snapshot.

search_indexElasticsearch / OpenSearch
block_idpage_idworkspace_idtexttitleancestorspermission principalslast_edited

Indexed 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 queue

Instant page opens and offline tolerance; reconciled with syncRecordValues.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAload / savesubscribe, presencedatabase viewscan edit?ancestor cachetxn + versionsrecord changedfan outCDCindex textsnapshotsfiles via CDNClient + SQLite cacheWebSocket gatewayLoad balancer / API gwPermission serviceBlock API (load/save)Collection query svcSearch serviceRedis pub/sub(changes)Kafka (CDC stream)Indexer + historyworkersRedis cachePostgres shards(blocks)S3 (files, snapshots)Elasticsearch
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

User types in a paragraph while a colleague watches

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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).
  7. 7The colleague's editor merges the new text, keeping their cursor position stable.

Open a large page

  1. 1The client renders instantly from SQLite if it has the page cached.
  2. 2In the background it calls syncRecordValues with cached record versions; the server returns only records that changed.
  3. 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.
  4. 4The client subscribes to the page over WebSocket so later changes arrive as pushes.
  5. 5Remaining chunks and images (via CDN) load as the user scrolls.

Move a sub-page to another parent

  1. 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.
  2. 2The server checks edit rights on the page, old parent and new parent, then applies all three operations atomically.
  3. 3Because permissions are inherited, the effective access of the moved subtree changes; the permission cache entries for that subtree are invalidated.
  4. 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

Q1How would you support full offline mode for a week of edits on a flight?
Q2How do you implement page history and restore to a point in time?
Q3How would you add AI Q&A over the workspace while respecting page permissions?
Q4How do you move a workspace from one shard to another without downtime?
Q5How would you implement synced blocks that appear on several pages?
Q6How would a database view with a filter on a relation property be computed efficiently?

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.

Start free