Guide · System design
Design a Collaborative Workspace Like Notion: The Block Model
25 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Notion system design starts from the block data model. Every paragraph, heading, to-do, image and even page is a block: one small database record with its own id, type and data.
- Blocks link into a tree. Each block lists its children in order, which is how a page is drawn, and stores one parent id, which Notion uses only to check permissions.
- An edit becomes a few operations, each one small change to one record, grouped into a transaction: a bundle saved completely or not at all. Your screen updates at once, and the server checks and saves it.
- Other open apps hear about the save over a WebSocket, a connection that stays open in both directions. They receive only a new version number, then fetch the changed blocks.
- Beginners can stop after the table comparing one document per page with blocks. Engineers then get runnable routing to 480 logical shards (slices of the data) and a permission cache (a saved copy of each answer) that leaks on moves. They also get Notion's 2025 move to CRDTs for rich text, which merge edits.
What goes wrong when a whole page is saved as one record?
Two people who save the same page overwrite each other, and nobody can move or share part of a page without copying it. That is the core problem behind Notion system design: letting many people edit small pieces of one page at once.
Picture a common setup. A small team builds a workspace app for creative agencies, who keep client briefs, mood boards and shoot schedules in it. The setup is made up, but the problem is real.
The first version is the naive one. Each page is one row in a database, and the whole page body sits in one text column as HTML, text with tags that mark headings, lists and bold. Saving means sending the whole body to the server and overwriting the row.
It works for the first 30 agencies (made up). Then three kinds of complaint arrive.
- Two designers edit the same client brief. Both press save, and the second save wipes out the first designer's 40 minutes of work.
- An account manager wants to move the shoot schedule into its own page. She has to copy it, paste it and fix the formatting by hand.
- A studio wants to share only the mood board with its client, not the budget notes on the same page. The app can only share whole pages.
All three have one cause. The page is one lump, so every save, move and share must treat it as one lump. The rest of this guide rebuilds the app around blocks, the idea Notion's own engineering blog describes.
What is Notion system design?
Notion system design is the interview task of designing a workspace where people build pages from small pieces and edit them together live. The heart of a good answer is the block data model.
A data model is the shape you choose for stored data: which records exist and how they point at each other. Saving, syncing, storage and permissions all depend on it, so it deserves the most time.
Notion's 2021 post on its data model says it plainly: everything you see in Notion is a block. Text, images, lists, database rows and pages themselves are all blocks. Once that is true, moving, nesting and sharing become small changes to small records.
What is a block in Notion's data model?
A block is one small record that holds one piece of content, such as a paragraph, a to-do or an image. Notion's post describes five main fields, shown in the table below.
| Field | What it holds | Example from the agency app |
|---|---|---|
| id | A random UUID version 4: a 128-bit label any device can create without asking a server | A new to-do gets its id the moment you press Enter |
| type | What kind of block it is, which decides how it is drawn and how its properties are read | to-do, heading, image, page |
| properties | The block's own data as key and value pairs; title holds the text | title Book the location, checked true |
| content | An ordered list of the ids of the block's children | A page lists the blocks on it, top to bottom |
| parent | The id of the one record this block sits under, used only for permissions | The to-do's parent is the brief page |
Think of a godhadi, the patchwork quilt stitched from old saris in Maharashtra and Karnataka. Each patch is one block. The layout of patches across the front is the content lists, and the one seam that holds each patch in place is its parent.
You can unpick a patch and sew it elsewhere without cutting the quilt. That is a move. Where the picture breaks: a cloth patch sits in one place only. A block id could be listed in two content lists, which is why Notion keeps a single parent.
One more detail shows why the model is kind to users. Changing a block's type changes only the type field. Turn a checked to-do into a heading and back again, and it is still checked, because the checked property never left.
How do blocks nest to form a page?
Blocks nest by listing each other in their content field, which builds a tree with the workspace at the root. To draw a page, the app starts at the page block and walks down each content list in order.
Here is one client brief from the agency app. Start at the workspace box and follow the arrows down. Each arrow is one entry in a content list.
A client brief as a tree of blocks
Drag to exploreEvery box is one block record. Notice that the mood board is itself a page block, so its image is drawn on a separate page.
Diagram connections:
- Workspace to Page block: Client brief
- Page block: Client brief to Heading block
- Page block: Client brief to To-do block
- To-do block to Nested to-do block
- Page block: Client brief to Page block: Mood board
- Page block: Mood board to Image block
- Workspace: The root of the tree
- Page block: Client brief: type page, title Tea brand launch
- Heading block: title Shoot schedule
- To-do block: Book the location, checked
- Nested to-do block: Get the permit, sits under the first to-do
- Page block: Mood board: A page inside a page
- Image block: Points to the uploaded image file
The type decides how children appear. Notion's post says list blocks such as text, bulleted lists and to-dos show their children indented. A toggle shows them only when opened. A page block shows them on a new page that you click into.
Pause and predict: the account manager moves the shoot schedule heading and its to-dos into a new page inside the brief. How many records change, and does the nested permit to-do change at all?
The answer: you create the new page block, remove the moved ids from the brief's content list, add them to the new page's list, and update each moved block's parent. That is four records: the new page, the brief, the heading and the first to-do. The permit changes nothing, because its parent is still the first to-do.
What just happened? The second complaint turned from copy and paste into a few small record updates. Children travel with their parent for free.
Two pointers: content goes down, parent goes up
Each link is stored twice. The content list points down from parent to child, and draws pages. The parent field points up from child to parent, and Notion's post says it is only used for permissions.
Why both? Going down needs order, and only a list has order. Going up needs exactly one answer, and a single field gives exactly one. The post adds that searching every content list to find a block's ancestors would be slow.
Storing a link twice has a cost: the two copies must agree. If a move updates the content list but not the parent, the block is drawn in one place and permission-checked in another. So both changes must be saved together.
Indenting a block, as a runnable sketch
Notion's post calls indentation structural: pressing indent moves a block into the content list of the block above it. If the block above is not a list block, or there is none, indenting does nothing. Here is that rule in Python.
The function changes nothing itself. It returns the operations needed, which the next section explains. The type names, and the three list types taken from the post's examples, are our simplification.
import uuid
from dataclasses import dataclass, field
LIST_TYPES = {"text", "bulleted_list", "to_do"} # blocks that can hold children
@dataclass
class Block:
type: str
properties: dict
parent: str = ""
content: list = field(default_factory=list)
id: str = field(default_factory=lambda: str(uuid.uuid4()))
def indent_ops(blocks, block_id):
old_parent = blocks[blocks[block_id].parent]
pos = old_parent.content.index(block_id)
if pos == 0:
return [] # no block above it, so nowhere to move
new_parent = blocks[old_parent.content[pos - 1]]
if new_parent.type not in LIST_TYPES:
return [] # the block above cannot hold children
return [("remove_child", old_parent.id, block_id),
("append_child", new_parent.id, block_id),
("set_parent", block_id, new_parent.id)]
page = Block("page", {"title": "Tea brand launch"})
book = Block("to_do", {"title": "Book the location", "checked": True}, page.id)
permit = Block("to_do", {"title": "Get the permit", "checked": False}, page.id)
page.content = [book.id, permit.id]
blocks = {b.id: b for b in (page, book, permit)}
print(indent_ops(blocks, book.id))
for op, a, b in indent_ops(blocks, permit.id):
print(op, blocks[a].properties["title"], "|", blocks[b].properties["title"])Run it with Python 3 and you should see exactly this. The empty list is the first to-do, which has nothing above it.
[]
remove_child Tea brand launch | Get the permit
append_child Book the location | Get the permit
set_parent Get the permit | Book the locationNotice that the last two operations touch both pointers. The permit leaves the page's content list, joins the first to-do's list, and gets a new parent. The id field in the Block class also shows why random UUIDs help: the device names a new block without a server trip.
How does Notion save an edit without making you wait?
It applies the edit on your screen first, then sends it to the server in the background as a transaction. The server checks it and saves it, or rejects it, and your app reacts either way.
Two words carry this section. An operation is one small change to one record, such as setting a block's title. A transaction is a group of operations that must all succeed or all fail together.
Notion's post walks through pressing Enter to make a new to-do. The client creates operations for the new block and its fields, plus one that adds its id to the parent's content list. All of them go into one transaction.
The post also says unsent transactions wait in a queue stored in IndexedDB or SQLite, two databases that live on your device. So a dropped connection does not lose the edit; it waits until the connection returns.
You press Enter
The app builds the new to-do, groups the operations into one transaction and shows the to-do at once.
The queue holds it
The transaction is stored in the device queue, then posted to a save endpoint, a server address the app sends saves to (Notion's is called saveTransactions).
The server loads a before picture
It loads every block and parent the transaction touches, so it knows what the data looked like before the edit.
The server builds an after picture and checks
It applies the operations to a copy, then compares before and after to check permissions and that the data still fits together.
Commit and reply
It saves every changed record and replies with success. The app can then move on to the next queued transaction.
Background work
Slower jobs follow: version history snapshots, the search index, and a notice to the real-time service so other people update.
Showing the edit before the server agrees is called an optimistic update. It makes typing feel instant. The price is that the app must handle a rejection, for example by rolling back the change and telling the user.
How do other people see your changes in real time?
Each open app keeps a live connection to a real-time service and subscribes to the records it is showing. When a record is saved, the service tells every subscriber its new version number, and they fetch the new data.
The live connection is a WebSocket. RFC 6455, the standard that defines it, describes two-way messages between a browser and a server over one long-lived network connection. Notion's post names its real-time service MessageStore.
Notice what travels over that connection: a new version number, not the content. The receiving app compares it with the version in its local cache, the copy of blocks it already holds. If they differ, it asks the server for the outdated records (Notion's request is called syncRecordValues) and redraws.
Follow one edit in the diagram. The database is written before the real-time service hears anything, so a teammate never fetches a version that is not saved yet.
One edit, saved and synced
Drag to exploreYour screen changes at once. A teammate's screen changes after the commit, one version notice and one small fetch.
Diagram connections:
- Your app to Save API (Transaction)
- Save API to Block database (Commit)
- Save API to Background jobs (After success)
- Save API to Real-time service (New versions saved)
- Real-time service to Teammate's app (Version notice)
- Teammate's app to Save API (Fetch outdated records)
- Your app: Shows the edit at once, then queues it
- Save API: Checks permissions and data fit
- Block database: Stores this workspace's blocks
- Background jobs: Version history and search index
- Real-time service: Knows who subscribes to which record
- Teammate's app: Fetches newer versions, then redraws
This fixes the first complaint for most cases. Two designers editing different blocks of one brief now change different records, so neither save overwrites the other. Edits to the very same block are the engineer section on conflicts.
How do permissions work in a block tree?
A block inherits its permissions from its ancestors. To decide whether someone may see a block, you walk up the parent pointers until you reach a block with its own sharing rule, or the workspace at the root.
This is the reason the parent pointer exists. If a block were listed in two content lists, the lists could give two different ancestors, and a permission check must give exactly one answer.
This design uses the simplest rule: the nearest ancestor with its own rule decides. Real products often combine rules along the path, such as a guest added to one page on top of workspace access. The walk is the same; it collects instead of stopping.
Here is the check as a loop. Start at the top and follow the No arrows. Each lap moves one level up the tree.
Can the agency's client see this block?
Drag to exploreThe walk stops at the first block with its own rule, or at the workspace. Each No arrow costs one more record read.
Diagram connections:
- Client opens a block to Block has its own rule?
- Block has its own rule? to Apply that rule (Yes)
- Block has its own rule? to Move to the parent block (No)
- Move to the parent block to Reached the workspace?
- Reached the workspace? to Apply workspace rule (Yes)
- Reached the workspace? to Block has its own rule? (No, check again)
- Client opens a block: The image on the mood board
- Block has its own rule?: A share setting stored on this block
- Apply that rule: Mood board page is shared with the client
- Move to the parent block: Follow the parent field one level up
- Reached the workspace?: The root of every tree
- Apply workspace rule: Only workspace members may see it
That fixes the third complaint. The studio shares only the mood board page. The image inside inherits that rule, while the budget notes on the parent brief keep the workspace rule and stay private.
Should you store a page as one document or as blocks?
Store blocks when people move, nest, share or edit small parts of a page together. Store one document per page when pages are mostly written by one person and read as a whole.
The table compares the agency app before and after the rebuild. Blocks make every small action cheap, and make whole-page reads and the overall system more work.
| One document per page | One record per block | |
|---|---|---|
| Rows for a 200-line brief | 1 | About 200 |
| Size of one save | The whole page, about 60 KB | A few changed blocks, under 1 KB |
| Two people editing one page | The last save wipes the other | A clash only on the same block |
| Moving a section to a new page | Copy, paste, fix formatting | Change a few content lists and parents |
| Sharing part of a page | Not possible | Share one page block and everything under it |
| Loading a page | One read | Walk the tree, many reads unless cached |
The loading row is the real cost. Notion's post says its page loader walks down the content tree from a starting block and uses several layers of caching, yet the worst case still needs many database trips.
How does Notion shard billions of blocks in Postgres?
It splits blocks across many Postgres databases (PostgreSQL, a widely used open-source relational database), using the workspace id as the key. All of one workspace's blocks then live in the same database. Most requests touch one database, and adding machines adds capacity.
The block model makes a lot of rows, because every bullet is a record. Notion's July 2024 post on its data lake says it had more than 20 billion block rows in Postgres at the start of 2021. By that post, it had more than 200 billion.
The same post adds a detail that shapes the design: 90% of Notion's upserts are updates, not new rows. An upsert is a write that inserts a row or updates it if it already exists. People mostly edit existing blocks.
Workspace id as the shard key
Sharding means splitting one big table across several databases, each holding one slice of the rows. The column that decides which slice a row goes to is the shard key, also called the partition key.
Notion's October 2021 post on sharding Postgres explains the choice. Each block belongs to exactly one workspace, and people usually work inside one workspace at a time. So queries that must join data across databases are rare.
The post gives the layout. Notion made 480 logical shards, each a Postgres schema (a named group of tables inside one database) holding its own copy of the block table and related tables. It spread them over 32 physical databases, 15 schemas on each.
Why 480? The post says 480 is divisible by a lot of numbers, so the team could later grow from 32 to 40 to 48 hosts and keep the spread even. It has 24 divisors: sum(480 % d == 0 for d in range(1, 481)) prints 24 in Python.
In its July 2023 post, Notion describes tripling the fleet from 32 to 96 machines, with 5 schemas on each. It copied data with Postgres logical replication, which streams every change from an old database to a new one, and reports no user downtime it could see.
A shard router you can run
Notion's post says all routing happens in the application but does not publish the formula. This sketch uses one simple choice: the workspace UUID modulo 480 picks the logical shard, and a division picks the database.
import uuid
LOGICAL_SHARDS = 480
def logical_shard(workspace_id):
return uuid.UUID(workspace_id).int % LOGICAL_SHARDS
def physical_db(shard, databases):
return shard // (LOGICAL_SHARDS // databases)
ws = "3f2a9c1e-7b4d-4e8a-9f10-2c6d8e5b7a41"
s = logical_shard(ws)
print("logical shard:", s)
print("database out of 32:", physical_db(s, 32))
print("database out of 96:", physical_db(s, 96))
for n in (32, 40, 48, 64, 96):
print(n, "databases ->", LOGICAL_SHARDS / n, "shards each")Run it with Python 3 and you should see exactly this. The same workspace is shard 225 on either fleet; only the database number changes.
logical shard: 225
database out of 32: 15
database out of 96: 45
32 databases -> 15.0 shards each
40 databases -> 12.0 shards each
48 databases -> 10.0 shards each
64 databases -> 7.5 shards each
96 databases -> 5.0 shards eachWhat just happened? Growing from 32 to 96 databases changed only the second step. With this formula, each old database's 15 schemas land on exactly three new databases, five each, and no logical shard is ever split. The 64 line shows why some machine counts would leave the spread uneven. The sketch's physical_db also assumes the count divides 480: for 64 it would return database numbers up to 68, past the last real one, 63.
Routing a block request to its shard
Drag to exploreOne workspace, one logical shard, one physical database. Counts follow Notion's 2021 layout of 32 databases.
Diagram connections:
- API server to Shard router
- Shard router to Connection pooler (db and schema name)
- Connection pooler to Database 00
- Connection pooler to Database 01
- Connection pooler to Database 31
- API server: Knows the workspace id of each request
- Shard router: Workspace id to one of 480 logical shards
- Connection pooler: PgBouncer shares a few connections per database
- Database 00: Schemas 000 to 014
- Database 01: Schemas 015 to 029
- Database 31: Schemas 465 to 479
Notice the pooler box. PgBouncer is a connection pooler: it lets many app servers share a small number of database connections. Notion's 2023 post names three pressures before the re-shard: some shards above 90% CPU at peak traffic, many near their disk bandwidth limit, and PgBouncer connection limits.
The post gives the numbers. About 100 PgBouncer instances could each open up to 6 connections to each database, about 600 per database. So before the cutover, the team split PgBouncer into 4 groups, each in front of 24 databases.
Why do permission checks get expensive, and how do you cache them?
Every walk up the tree costs one record read per level, and Notion's data lake post says ancestor depths range from a few to dozens. Cache the computed rule per block, and clear it whenever a block moves or a share rule changes.
Scope matters here. The 2024 post describes this cost in Notion's analytics pipeline. Block permissions are not stored ready-made in Postgres, and building them for every block by tree walks timed out in its old data warehouse. The live app's caching is not described there.
This sketch walks up parents, stores the answer for every block on the path, and clears the cache after a move. Each result is the rule and the number of blocks walked before finding it.
blocks = {
"workspace": {"parent": None, "rule": "members only"},
"brief": {"parent": "workspace", "rule": None},
"budget": {"parent": "brief", "rule": None},
"board": {"parent": "brief", "rule": "shared with client"},
"image": {"parent": "board", "rule": None},
}
cache = {}
def rule_for(block_id):
walked, node = [], block_id
while node not in cache and blocks[node]["rule"] is None:
walked.append(node)
node = blocks[node]["parent"]
rule = cache.get(node) or blocks[node]["rule"]
for b in walked + [node]:
cache[b] = rule
return rule, len(walked)
print(rule_for("image"))
print(rule_for("budget"))
print(rule_for("image"))
blocks["image"]["parent"] = "brief" # move the image out of the board
cache.clear() # a move must clear cached rules
print(rule_for("image"))Run it with Python 3 and you should see exactly this.
('shared with client', 1)
('members only', 2)
('shared with client', 0)
('members only', 2)The third line is the cache paying off: zero blocks walked. Now delete the cache.clear() line and run it again. The last line becomes ('shared with client', 0), so the image keeps its old share rule after leaving the shared board.
That stale answer is a data leak, not a slow page. A real system would clear only the moved block and its descendants, but the rule is the same: every move and every share change must reach the cache.
What happens when two people edit the same block in Notion?
Under Notion's earlier last write wins approach, whichever save came last decided the result. Its September 2026 post on concurrent editing says one person's edits to the same block could be completely lost. In July 2025, Notion deployed a CRDT system for rich text to production.
A CRDT (conflict-free replicated data type) is a data structure designed so that copies edited separately always merge to the same result. The older alternative, operational transformation, rewrites each incoming edit to fit edits that already happened.
Notion's post names its building blocks. It starts from RGA (Replicated Growable Array), a classic sequence CRDT that gives every inserted character a stable id. It adds operations based on Peritext, a published algorithm for merging formatting such as bold and links.
Deleted characters stay in the structure as tombstones, markers that say removed. An offline edit that arrives late may still point at those ids, so they cannot vanish. Text that splits across blocks keeps a text instance id, so an operation can still find it.
For your own design, a sensible split is per property type: last write wins for small fields like a checkbox, and a text CRDT for paragraphs. That split is a design choice, not something Notion's posts describe.
How do you explain Notion system design in a system design interview?
Lead with the block data model, then show how saving, syncing, sharding and permissions each follow from it. Keep one running example, and tie every choice to a user problem it solves.
Pin down the scope
Ask whether you need live collaboration, offline editing, sharing parts of pages and search. Agree on rough scale: workspaces, users and blocks per page.
Draw one block
Write out id, type, properties, content and parent. Explain why ids are random UUIDs made on the device.
Draw one page as a tree
Show content lists going down for drawing and parent pointers going up for permissions. Say both must change in one transaction.
Walk through one edit
Operations, a transaction, an optimistic update, a device queue, a server check of before and after, then a commit.
Add live sync
Clients subscribe over WebSockets. The server pushes a version number, and clients fetch only the outdated records.
Scale and protect
Shard by workspace id with many logical shards on fewer machines. Check permissions by walking parents, and cache with clear-on-move.
Close with conflicts
Last write wins for small fields, and a text CRDT for paragraphs and offline edits. Mention tombstones if asked how late edits find their place.
Questions people ask
- What database does Notion use for blocks?
- Notion's posts up to July 2024 describe blocks stored in PostgreSQL, sharded by workspace id. Its October 2021 post describes 480 logical shards on 32 physical databases, and its July 2023 post describes growing to 96 databases with 5 shards each. A separate data lake, described in a July 2024 post, serves analytics, search and AI work.
- Why does Notion use UUIDs for block ids?
- Random version 4 UUIDs let any device create a block id on its own, with no trip to a server. New blocks appear instantly, and offline edits can create blocks safely. A version 4 UUID has 122 random bits, so even 200 billion of them have about a 4 in a thousand trillion chance of any repeat.
- Is a Notion page a block?
- Yes. A page is a block whose type is page. Its content list holds the blocks on that page, and it shows them on a new page you click into instead of inline. That is why you can nest pages inside pages and move a page like any other block.
- How is the block model different from a document database?
- A document database usually stores one whole document, such as a page, as one record. The block model stores each piece of the page as its own record and links them into a tree. That makes small edits, moves and partial sharing cheap, but whole-page reads need more work and caching.
- Does Notion use CRDTs?
- Yes, for rich text. Notion's September 2026 post says it deployed a CRDT system to production in July 2025, built on RGA with Peritext-based formatting operations. Its December 2025 post says pages marked for offline use move to that CRDT data model. Neither post explains how non-text properties merge.
- What should I draw first in a Notion system design interview?
- Draw one block with its fields, then one small page as a tree of blocks. Saving, syncing, sharding and permissions all build on that picture. Starting with servers and databases before the data model usually leads to a vague answer.
Sources and further reading
- The data model behind Notion's flexibility (Notion engineering blog, May 2021): blocks, pointers, transactions, MessageStore
- Herding elephants: lessons learned from sharding Postgres at Notion (Notion engineering blog, October 2021)
- The Great Re-shard: adding Postgres capacity (again) with zero downtime (Notion engineering blog, July 2023)
- Building and scaling Notion's data lake (Notion engineering blog, July 2024): block counts, update-heavy writes, permission walks
- How Notion handles concurrent editing with CRDTs (Notion engineering blog, September 2026)
- How we made Notion available offline (Notion engineering blog, December 2025)
- RFC 6455: The WebSocket Protocol (IETF, December 2011)