Design Notion
A web app where every paragraph, heading, image, and page is a block in one big tree, edited by many people at once, with databases built from the same blocks. The hard parts are a data model flexible enough for all of it, editing and dragging blocks without breaking the tree, merging concurrent edits, and loading huge pages fast, online and offline.
The question
Design the web client for a workspace app like Notion. A page holds paragraphs, headings, to-do items, toggles, images, code, and embeds, and any of them can be dragged around, nested under each other, or turned into another type. Pages live inside other pages, forming a tree that shows in a sidebar.
Some pages are databases: tables of pages with typed properties, shown as a table, a board, a calendar, or a gallery. Several people can edit the same page at once. Walk me through the data model, how pages render and are edited, how blocks move, how edits from different people merge, and how big pages load fast, even offline.
Real-life examples: Notion, Coda, Craft, Confluence (new editor), AppFlowy, Microsoft Loop
Requirements: scoping a block editor and workspace
"Design Notion" hides at least five products: a document editor, a page tree like a file system, a small database engine with views, a real-time sync engine, and a publishing tool that turns pages into websites. An hour can cover one of them deeply and the rest at a sketch.
So spend the first five minutes on questions whose answers move the design. Ask about the shape of the content first, because Notion's whole idea is that everything is a block. Then ask about size, collaboration, and offline.
The table lists the questions a senior engineer would ask, the answer this guide assumes, and what changes in the design if the answer is different. Write the assumed answers on the board. Every number later in the guide comes from them.
| Question | Answer we assume | What it changes |
|---|---|---|
| What can a page contain? | Text blocks (paragraph, headings, lists, to-dos, toggles, quotes, callouts, code), media (image, file, video, embed), dividers, columns, sub-pages, and databases. | The block types, the registry that renders them, and how generic the data model must be. |
| How big does a page get? | Most pages have 20 to 300 blocks. The worst have 5,000 to 20,000 blocks (long meeting notes, imported wikis). | Whether we load and render the whole page at once, or in chunks. |
| How big is a workspace? | A team workspace has 10,000 to 1,000,000 pages, nested up to 10 or more levels deep. | The sidebar must load lazily, and search must run on the server. |
| How big are databases? | Typically 50 to 5,000 rows, sometimes 100,000 or more, with 5 to 30 properties each. | Where filters and sorts run, and whether views virtualize rows. |
| How many people edit one page at once? | Usually 1 to 3. Up to 20 to 50 in a meeting where everyone types notes. | How we merge edits and how often presence updates are sent. |
| Must it work offline? | Yes for pages the user marked or recently opened, on desktop and mobile; the web keeps working through short drops. | A local database on the device, an outbox of operations, and conflict handling on reconnect. |
| Can pages be public? | Yes, a page can be shared to the web and must be indexable by search engines. | A separate server rendered read-only path for public pages. |
Functional requirements
- Open a page and see its first screen of content within about 1.5 s on a warm cache and 3 s cold, even when the page is huge.
- Type into any text block with rich formatting (bold, links, mentions, inline code); press Enter to split a block, Backspace at the start to merge it into the one above.
- Turn any block into another type, from a slash command menu (type
/) or a block menu, without losing its text or children. - Indent and outdent blocks with Tab and Shift+Tab, so blocks nest under each other; toggles and list items show their children.
- Select many blocks at once, then move, delete, copy, duplicate, or recolor them together. Paste blocks, and paste HTML or Markdown from other apps as real blocks.
- Drag blocks by a handle to reorder them, nest them, or build side by side columns; do the same from the keyboard.
- Browse nested pages in a sidebar tree; create, move, and delete pages; see breadcrumbs for where a page lives.
- Create databases whose rows are pages with typed properties, and view them as a table, board, calendar, gallery, or list, with filters, sorts, and groups.
- Edit together: see other people's cursors and edits within about 300 ms; undo and redo your own changes.
- Search the whole workspace; share a page with people or publish it to the web.
Non-functional requirements
- Typing latency: a keystroke shows in the next frame, under about 50 ms from key to pixels, even with 50 people on the page. Typing must never wait for the network.
- Load time: Largest Contentful Paint (LCP) under 2.5 s for a page's first screen, and Interaction to Next Paint (INP) under 200 ms for typing, slash menu, and drag.
- No lost work: every edit is saved on the device at once and reaches the server within a second or two when online; a crash or reload never loses more than the last keystroke.
- Convergence: two people editing the same page always end up seeing the same blocks in the same order.
- Memory: a 10,000 block page stays under about 300 MB in the tab.
- Permissions: a user never sees a block, a page title, or a search result they don't have access to.
- Accessibility: every action (move, indent, turn into, open menus) works by keyboard, and blocks are announced sensibly by screen readers.
Out of scope, and why
- The rich text editor's internals (selection, input events, marks): covered in depth in the guide Design a rich text editor. Here we treat each text block as using one, and focus on what sits around it.
- Character level merging of text typed at the same spot by two people: covered in the guide Design Google Docs. We decide only where it applies.
- AI features, integrations, automations, and billing: they read and write the same blocks, so they add little to the client design.
- The server's storage engine (sharded Postgres at Notion): we name it where it shapes the API, nothing more.
Check your understanding · Exercise 1
Which answers from the interviewer would change the client design the most? Select all that apply.
What makes Notion hard
A plain document editor treats a page as one long string of rich text. Notion breaks that idea on purpose: a page is a tree of small, independent units called blocks, and almost everything you can see is one. That one choice is what makes the product flexible, and it is also the root of every hard problem below.
- It is a tree, not a string. A toggle holds children, a column holds blocks, a page holds pages. Every edit, from pressing Enter to dragging a block, changes the tree's shape. A bug that leaves a block with two parents, or none, loses content.
- It is many editors, not one. Each text block holds its own rich text. The caret moves across block boundaries, selections span many blocks, and copy and paste must cross them too. The browser's
contenteditablewas not built for that. - It is shared and live. Up to 50 people edit the same tree at once. One person indents a block while another deletes its parent. Both clients must end up with the same tree, without a central lock and without making anyone wait.
- It is huge and deep. A workspace can hold a million pages, and one page can hold 20,000 blocks. We can never load everything; we load what the screen needs and fetch the rest lazily.
- It is a database too. A database is a collection of pages with typed properties, and the same rows show as a table, a board, or a calendar. Filters and sorts must work on 50 rows instantly and on 100,000 rows without freezing the tab.
- It must work offline and be public. The same blocks must be stored on the device for offline work, and also rendered on the server as fast, indexable HTML for pages published to the web.
Keep these six in mind. Each later chapter takes one of them, starts from the simplest thing that could work, shows where it breaks, and picks a better design. The order follows the data: first what a block is, then how blocks render and change, then how they move, how they sync, and how they load.
Think first
Before reading on: why not store a Notion page as one HTML string, like a blog post? List two things that become hard.
Moving a paragraph means cutting and pasting a slice of a big string, and two people moving different paragraphs at once would fight over the same string. With blocks, a move is one small change to one block's parent and position.
Turning a paragraph into a database row, linking to a single block, commenting on one block, or loading only the first 100 blocks are all awkward with a single string. Each needs a stable identity for a piece of content, which is exactly what a block id gives you.
The block data model: everything is a block
Start with the data, because every later chapter depends on it. The naive design stores each page as one document: a JSON tree or an HTML string, saved and loaded as a whole. It works for a blog. It breaks for Notion in three concrete ways.
- Saving is all or nothing. On a 5,000 block page (about 1 MB of JSON), every keystroke would rewrite the whole document. Two people typing in different paragraphs would overwrite each other's saves unless the server merges two 1 MB trees.
- Nothing has a stable identity. A comment, a link to a block, a database row, and a synced block all need to point at one piece of content that survives moves and edits. Positions in a string shift with every edit.
- Loading is all or nothing. You can't send the first screen of a page without parsing and sending the whole thing.
Decision How do we store the content of pages?
What we pick: Flat block records. This is also what Notion describes in its post on its data model: every block has an id (a random UUID v4), a type, properties, a content array of child ids, and a parent id. The cost, keeping two sets of pointers in agreement, is paid once in a small set of well-tested operations.
Anatomy of a block
Five fields describe a block. id is a random UUID created on the client, so a new block exists before the server hears of it. type says how to draw it: text, header, to_do, toggle, image, page, and so on.
properties holds its data; the most common one is title, the rich text of a paragraph or the name of a page. content is the ordered list of child ids, the blocks drawn inside it. parent points up to the block that contains it.
type BlockId = string; // UUID v4, made on the client
type RichText = { text: string; marks?: Mark[] }[];
type Mark =
| { type: "bold" } | { type: "italic" } | { type: "code" }
| { type: "link"; href: string }
| { type: "mention"; pageId: BlockId };
type Block = {
id: BlockId;
type: "page" | "text" | "header" | "to_do" | "toggle"
| "bulleted_list" | "image" | "column_list" | "column" | "collection_view";
properties: { title?: RichText; checked?: boolean; [key: string]: unknown };
format?: { color?: string; width?: number; icon?: string };
content: BlockId[]; // ordered children ("downward pointers")
parent: { table: "block" | "space"; id: BlockId };
alive: boolean; // false once deleted; kept so undo can restore it
spaceId: string; // the workspace
version: number; // bumped by the server on every change
lastEditedBy: string;
lastEditedTime: number;
};
content, and each child points back with parent, so moving a block changes ids in two places and nothing else.Why "Turn into" is nearly free
Notice what is not in the block: no field that only makes sense for one type. A to-do and a heading both store their text in title, and a to-do also has checked. When you turn a checked to-do into a heading, only type changes; checked stays in properties and the heading's renderer ignores it. Turn it back into a to-do and it is still checked.
Notion's post uses exactly this example. For us it means "Turn into" is a one-field update, which also merges cleanly with someone else editing the text at the same moment.
Two pointers: content down, parent up
Why store both directions? Rendering walks down: draw a block, then draw each id in its content. Permissions walk up: to know whether you may read a block, the server climbs parent pointers to the page and the workspace and checks the sharing settings on the way.
Climbing up through content arrays would mean searching every block for the one that lists you. Notion's post adds a second reason: early on, a block could appear in more than one content array, which made "who is my parent" ambiguous, and permissions can't be ambiguous.
Keeping the two in agreement is our job. Every structural edit (create, delete, move, indent) is written as a small group of operations that change both sides together: remove the id from the old parent's content, add it to the new parent's content, and set the block's parent. Never one without the other.
The client store: a normalized record cache
On the client the blocks live in a normalized store: a map from id to record, never nested copies. This is the pattern from the lesson on normalization. Rendering a block looks up its record by id; changing a block updates one entry, and every view showing it (the page, the sidebar, a backlink, a synced block) updates together. Notion's post calls its version RecordCache: records in memory, and in the native apps an LRU (least recently used) cache on top of SQLite or IndexedDB on disk.
| Record type | Key | What it holds |
|---|---|---|
block | block id | Every block, including pages and database rows. |
collection | collection id | A database's schema: property names and types. |
collection_view | view id | One view of a database: layout, filters, sorts, groups. |
space | workspace id | Workspace settings and its top-level pages. |
notion_user | user id | Names and avatars, for mentions and presence. |
discussion and comment | id | Comment threads attached to a block. |
Check your understanding · Exercise 2
A user turns a checked to-do block into a callout, then back into a to-do. With the flat block model, which statements are true? Select all that apply.
Rendering a tree of blocks
With blocks in a normalized store, rendering a page is a walk down the tree: draw the page block, then each id in its content, then their children, and so on. Two questions decide how good this is. How does the renderer know what to draw for each type? And how is text editing wired in: one editor per block, or one for the whole page?
A block registry: type to component
The naive renderer is a big switch on block.type inside one component. With 40 types it grows to thousands of lines, pulls the code for every type (code highlighting, math, PDF viewer, database views) into the first bundle, and every new type touches the core. Instead, keep a registry: a map from type to a small descriptor holding the component, whether the block holds text, whether it can have children, and how it handles keys like Enter. The page renderer only knows the registry.
type BlockSpec = {
component: React.ComponentType<{ id: BlockId }>;
hasText: boolean; // edited with rich text
childrenLayout: "indented" | "hidden-until-open" | "none" | "page-link";
enterCreates?: Block["type"]; // to_do makes another to_do
};
const registry: Record<string, BlockSpec> = {
text: { component: TextBlock, hasText: true, childrenLayout: "indented" },
to_do: { component: TodoBlock, hasText: true, childrenLayout: "indented", enterCreates: "to_do" },
toggle: { component: ToggleBlock, hasText: true, childrenLayout: "hidden-until-open" },
page: { component: PageLink, hasText: false, childrenLayout: "page-link" },
code: { component: lazy(() => import("./CodeBlock")), hasText: true, childrenLayout: "none" },
collection_view: { component: lazy(() => import("./DatabaseView")), hasText: false, childrenLayout: "none" },
};
function BlockView({ id }: { id: BlockId }) {
const block = useRecord("block", id); // subscribes to this one record
if (!block) return <BlockPlaceholder id={id} />;
const spec = registry[block.type] ?? registry.unsupported;
return <spec.component id={id} />;
}
Three details matter. First, BlockView subscribes to one record, not the whole page, so typing in block 4,000 re-renders only block 4,000; wrap it in React.memo and pass only the id. Second, heavy types load with lazy() behind a fixed height placeholder, which keeps the first bundle small (see the lesson on code splitting).
Third, an unknown type, perhaps from a newer client, renders a harmless "unsupported block" instead of crashing. Old clients will meet new types, so this case is normal, not an edge case.
Each type also decides how its children are drawn. Notion's post calls the children "render children": a list item draws them indented, a toggle draws them only when open, and a page block draws only a link, because its content lives on its own page. A column_list draws its column children side by side. The tree is the same everywhere; the layout is the type's choice.
Editing text: one editor per block, or one for the page?
Most blocks hold rich text, and the browser's tool for editable rich text is contenteditable. The guide Design a rich text editor covers what a good editor does inside: a model of the document, input handled through beforeinput, marks, IME composition. Here the question is how many editors the page has.
Decision How is text editing attached to the block tree?
What we pick: One editable element per text block, with a page-level controller that owns the caret, keys at block edges, and multi-block selection. It matches the record model, isolates remote changes, and keeps chunked loading possible. Inspecting Notion's web app in DevTools shows the same shape at the time of writing: each text block has its own editable element tagged with its block id. Pick one editor per page instead when pages are small and the content is mostly prose.
The page controller is the glue. It keeps the focus state: which block holds the caret and at which offset, or which blocks are selected as whole blocks. It listens to keydown at the page level for the keys that cross blocks (arrows at edges, Enter, Backspace, Tab, Escape) and to beforeinput inside a block for normal typing. When it moves the caret to another block, it calls focus() on that block's element and places the range with Selection.collapse().
Check your understanding · Exercise 3
Your page re-renders all 3,000 blocks on every keystroke, and typing lags by 120 ms. Which changes help? Pick one.
Block operations: split, merge, indent, turn into, select, paste
Most editing in Notion is not typing letters; it is changing the tree. Enter splits a block in two, Backspace at the start merges a block into the one above, and Tab moves a block under its previous sibling. Each of these touches several records at once, and each must be undoable, sent to the server, and merged with other people's edits. So before looking at any single key, we need the unit of change.
The unit of change: operations grouped in transactions
An operation is the smallest change to one record: set a field, or add or remove an id in a list field. A transaction is the group of operations that one user action produces, applied and saved together, all or nothing. Notion's data model post describes exactly this: actions in the UI become operations on single records, batched into transactions that the server commits or rejects as a group.
type Op =
| { kind: "set"; table: "block"; id: BlockId; path: string[]; value: unknown }
| { kind: "update"; table: "block"; id: BlockId; fields: Partial<Block> }
| { kind: "listInsert"; table: "block"; id: BlockId; path: ["content"];
item: BlockId; after?: BlockId; before?: BlockId }
| { kind: "listRemove"; table: "block"; id: BlockId; path: ["content"]; item: BlockId };
type Transaction = {
id: string; // for idempotent retries
spaceId: string;
ops: Op[];
clientTime: number;
};
Notice that a list insert says "after block X", not "at index 3". If someone else inserts a block above at the same time, index 3 now points at the wrong place, but "after X" still means what the user meant. We come back to ordering when blocks start moving, in the next chapter.
Enter and Backspace: split and merge
Say the caret sits after "Buy " in a to-do block B1 whose text is "Buy milk", and the user presses Enter. The controller builds one transaction: create a new block B2 of type to_do (the registry's enterCreates) with title "milk", set B1's title to "Buy ", and insert B2 into the parent's content right after B1. B1 keeps its id and its children, so comments and links that point at it stay put. Then the controller focuses B2 at offset 0.
Backspace at offset 0 is the reverse: append B2's text to B1, move B2's children to the end of B1's content (or after B1, depending on type), remove B2, and put the caret at the join.
- Enter in an empty list item outdents it instead of creating another empty item, as every word processor does.
- Enter at the start of a block inserts an empty block above and keeps the caret where it is, so the original block keeps its id, comments, and links.
- Backspace at the start of a list item or to-do first turns it into plain text; only a second Backspace merges. A type change is undone before a block is destroyed.
- Merging into a non-text block (an image above) selects the image instead of merging.
Tab and Shift+Tab: indent and outdent change the parent
In a word processor, indent is margin. In Notion, indent is structure: Tab moves the block into the content of its previous sibling, at the end. If the previous sibling can't hold children, or there is none, Tab does nothing. Shift+Tab moves the block out to its grandparent, right after its old parent.
Outdenting a block that has younger siblings forces a choice. Either they stay in the old parent, and the outdented block visually jumps below them, or they become the outdented block's children, and the visual order is kept. Outliners differ here; this design keeps the visual order, because a block that jumps away from the caret's line feels like a bug.
No transaction yet. Operations: Pick a block, then a key.. The page now reads: b1 Trip to Lisbon, b2 Passport and ID, b3 Flight tickets, b4 Hotel booking, b5 Packing notes. Transactions so far: 0.
Think first
Toggle T has children A, B, C. You outdent B, keeping the visual order. Write the operations.
Remove B from T's content. Remove C from T's content and append it to B's content (C was after B, so it becomes B's child). Insert B into T's parent's content right after T. Set B's parent to T's parent and C's parent to B.
All of it is one transaction, so undo restores the old tree in one step, and the server never sees a half-moved state.
Turn into and the slash menu
Typing / in a text block opens a menu of block types and actions, filtered as you type (/to shows To-do list, Toggle, Table of contents). It is a combobox: focus stays in the block, the highlighted item is announced through aria-activedescendant, arrows move the highlight, Enter applies, and Escape closes and leaves the typed text.
Choosing a type on an empty block is an update of type; on a block with text it either converts the block or inserts a new block after it, depending on the item. The menu's item list loads as its own small chunk the first time / is typed.
Selecting many blocks at once
Because each block is its own editable element, the browser can't select text across two blocks in a way our model understands. So the controller has two selection modes. Text selection lives inside one block and uses the native Selection. Block selection is a list of selected block ids, drawn as a blue highlight.
You enter block selection by pressing Escape in a block, by dragging a rectangle from the page margin, by Shift+click, or when a text drag leaves its block. In block mode, Shift+Arrow grows the range, Delete removes all, Tab indents all, and Cmd+D duplicates. Each of these is one transaction over many blocks.
Block selection is stored as a range in tree order, not a set of random ids. Selecting from a block inside a toggle to a block outside it selects the whole toggle, so a delete never orphans children. Selected blocks are announced with aria-selected on the block elements, and the page keeps one focused element so screen readers stay oriented.
Copy and paste of blocks
On copy, the controller writes several formats to the clipboard at once in the copy event: our own JSON of the block subtree (in a custom MIME type that only our app understands), text/html for other apps, and text/plain (Markdown-ish) as a fallback. On paste, it reads the richest format it understands.
- Our own blocks: create copies with fresh ids (so the copy is not the same block twice), keep types, properties, and children, and insert them after the caret block in one transaction.
- HTML from a web page or Google Docs: parse it with
DOMParser(never inject it into the page), map elements to blocks (h1toheader,lito a list block,imgtoimage,pretocode), keep only safe marks, and drop scripts, styles, and event handlers. Unknown tags fall back to plain text blocks. - Plain text that looks like Markdown: lines starting with
#,-,1., or[ ]become headings, lists, and to-dos; a fenced block becomes acodeblock. - A single URL: offer a choice, link, bookmark, or embed.
- Large pastes (a 2,000 line document): build the blocks in slices that yield to the main thread between them (Markdown can also be parsed in a worker;
DOMParserruns only on the main thread), insert them in one transaction, and show a progress state rather than freezing the tab.
Check your understanding · Exercise 4
Which statements about block operations are correct? Select all that apply.
Moving blocks: drag and drop, ordering, and concurrent moves
Hover a block in Notion and a six-dot handle appears in the left margin. Drag it and a blue line shows where the block will land: between two blocks, nested one level deeper, or beside another block to make columns. Drag and drop looks like a small feature, but it is where the tree model, the pointer, scrolling, and accessibility all meet.
Decision Which browser mechanism powers block dragging?
What we pick: Pointer events for moving blocks inside the app, because we need precise drop rules and touch support. Keep the native API only for the edges: files dropped from the desktop become image or file blocks, and dragging blocks out to other apps puts HTML and text on the data transfer.
Finding the drop target
On drag start, snapshot each visible block's rectangle with getBoundingClientRect() once (reading layout on every move would force reflow at 60 events a second). On each move, find the block under the pointer's y with a binary search over the snapshot. The upper half of the block means "before it", the lower half means "after it".
Then use the pointer's x to choose the depth: every 24 to 30 px to the right of the block's left edge is one level deeper, limited to one level deeper than the block above (you can only nest under a block that is directly above). Moving left lets you drop at a shallower level, up to the ancestors whose last child is that block.
- Columns: if the pointer is in the left or right 15 to 20 percent of a block's width, the indicator turns vertical. Dropping wraps both blocks in a
column_listwith twocolumnchildren, or adds a new column if the target is already in one. When a column empties, remove it; when one column remains, unwrap thecolumn_list. - Illegal drops: a block can never be dropped inside itself or its own descendants. Check the target's ancestors against the dragged ids and show no indicator.
- Dragging many blocks: if the handle belongs to a selected block, drag the whole selection, keeping its relative order.
- Dragging onto a page in the sidebar or a page link moves the block to the end of that page, which can be a different page from the one on screen.
Auto-scroll
Pages are long, so dragging must scroll. When the pointer is within about 60 px of the top or bottom of the scroll container, scroll on every animation frame with a speed that grows as the pointer nears the edge (for example 2 px per frame at 60 px away up to 20 px per frame at the edge). Use requestAnimationFrame, not pointermove, because a still pointer near the edge must keep scrolling. After each scroll, shift the cached rectangles by the scroll delta instead of measuring again.
The keyboard alternative
Dragging is not available to everyone. A keyboard user selects a block (Escape), then moves it with a shortcut (Notion offers Cmd+Shift+Up and Down, Ctrl on Windows), indents with Tab, or opens the block menu and uses "Move to" to pick another page. Each move is announced through a polite live region: "Moved Shopping list below Groceries, level 2". This is the same move transaction as a drag, so the sync and undo code doesn't care which input made it.
Check your understanding · Exercise 5
Which choices make block drag and drop correct and smooth? Select all that apply.
Ordering children and concurrent moves
Where a block lands is only half of a move; the other half is how the new order is stored. A parent's children have an order, and two people can change it at the same moment. Ana drags "Decisions" to the top while Ben inserts a new block after "Agenda" and Chen moves "Budget" to another page. Every client must end up with the same order, no block may vanish or appear twice, and nobody waits for a lock.
The naive way is the parent's content array, edited by index: "insert at 2", "remove at 5". Indexes are positions in one client's view, and they go stale the moment another client changes the list. If Ana removes index 0 while Ben inserts at index 3, applying Ben's insert after Ana's removal puts his block one slot lower than he meant. Two concurrent moves of the same block are worse: each is a remove plus an insert, and replaying both can leave the block in both places or in neither.
Decision How do we store and change the order of a block's children?
What we pick: Fractional indexes on each child, with parent and order written together as one value. A move then can never duplicate or lose a block: whichever move the server applies last wins, and every client converges on it. The guide Design Figma uses the same technique for layers. Notion's published model is the first option, and it works well while a central server orders every transaction; we choose fractional keys because this design also has to merge pages edited offline. From here on, a block's content is no longer stored: each client derives it by sorting the children of a parent, and the rest of the guide still calls that ordered list content.
Ana: Move D to the top. Ben: Move D after A. Ana's change reaches the server first. Merged with fractional keys: 1 Agenda; 1.5 Decisions; 2 Budget; 3 Contacts. With index operations: index ops: D, D, A, B, and D appears twice, C is gone.
How fractional keys work
Think of each key as a number between 0 and 1 whose digits are stored as a string, so plain string comparison sorts them. To insert between 0.2 and 0.3, pick 0.25; between 0.25 and 0.3, pick 0.27. There is always room for one more key, so no other block ever has to change. The lab above shows keys as small plain numbers for readability.
Real implementations use base 62 digits plus a short integer part, giving keys like a0, a1, and a0V (between the two). Appending 1,000 blocks one after another keeps keys at 2 to 3 characters. Squeezing 1,000 inserts into one gap grows them by about one character per 6 inserts, which is why keys are rebalanced occasionally.
type Placement = { parent: BlockId; order: string }; // one LWW value
type PlacedBlock = Omit<Block, "content" | "parent"> & { placement: Placement };
function childrenOf(parent: BlockId, all: Map<BlockId, PlacedBlock>) {
return [...all.values()]
.filter((b) => b.placement.parent === parent && b.alive)
.sort((x, y) => {
const a = x.placement.order, b = y.placement.order;
return a < b ? -1 : a > b ? 1 : x.id < y.id ? -1 : 1;
});
}
// A move is one set on one record:
const op: Op = { kind: "set", table: "block", id: moved,
path: ["placement"], value: { parent: target, order: keyBetween(prev, next) } };
The cases that need rules
| Concurrent edits | What happens | Why it's acceptable |
|---|---|---|
| Ana and Ben insert a block at the same spot | Both blocks appear, ordered by key, then by id. | Nothing is lost; both users see the same order after sync. |
| Ana moves block X to page P, Ben moves X to page Q | The move the server applies last wins; X ends up in exactly one place. | Placement is one value, so there is no half state. |
| Ana moves X under Y, Ben deletes Y | X now has a deleted parent. The server (or merge step) moves X to the end of the nearest live ancestor, here the page, or keeps it in trash with Y. | Content is never silently lost; it is restorable. |
| Ana moves X under Y, Ben moves Y under X | Applying both would make a cycle. The server rejects or reverts the later move. | A tree must stay a tree; cycles detach both blocks from the page. |
| Ana edits X's text, Ben moves X | Both apply: text and placement are different fields. | Different fields never conflict. |
Check your understanding · Exercise 6
Two clients move the same block at the same time, one to the top of the page and one into a toggle. With fractional indexes and placement as one value, what happens? Select all that apply.
Nested pages, the sidebar, permissions, and sharing
A page is a block of type page, and its sub-pages are page blocks in its content. So the whole workspace is one giant tree, and the sidebar is a view of just the page blocks in it. A workspace of 200,000 pages means the naive sidebar, which fetches the whole tree on load, downloads megabytes of titles and icons the user will never expand, and takes seconds before the app is usable.
What we pick: Load one level at a time, and hide most of the latency: remember which rows were expanded and fetch those levels in parallel at start, prefetch a page's children on hover of its arrow (about 100 ms head start), and keep the levels in the local cache so the next start is instant. Each row needs a hasChildren flag so the arrow can show before the children load.
Permissions flow down the tree
Sharing in Notion is set on a page and inherited by everything below it, unless a sub-page sets its own. Notion's post explains that this is why the parent pointer exists: to check access to a block, the server walks up the parents to the nearest page with explicit sharing rules. For the client this has three consequences.
- The server filters, the client trusts nothing. Every response (page chunks, sidebar levels, search results, backlinks) contains only records the user can read. A mention of a page you can't see renders as a grey "No access" chip, because the client never received its title.
- Moving a page can change who sees it. A page that inherits its access follows its new parent, so moving it from a shared teamspace into your private page can remove access for people who had it through the old parent. Before committing such a move, the client asks the server for the effect and shows a confirmation with the access change.
- Losing access is an event. If someone removes you from a page while you have it open, the server's next update says so; the client closes the editor, drops the cached records for that subtree from memory and disk, and shows a message.
Sharing a page with people
Sharing sets an access level for a person, a group, or "anyone with the link" on a page: full access, can edit, can comment, or can view. The share menu shows who has access and where it comes from ("inherited from Projects"). Changing it is a server call, not a local transaction, because the server must check the requester's own rights first. For the same reason, Notion's help center says sharing and permission changes wait until you are back online.
Breadcrumbs and moving pages
The breadcrumb at the top of a page (Workspace / Projects / 2026 / Launch plan) is the chain of parent pointers from the page up to the root. The page load response includes these ancestors' titles and icons, so the breadcrumb renders with the page and doesn't need a second request. Long chains collapse the middle into a ... menu.
Moving a page, whether by dragging it in the sidebar or with "Move to", is the same placement change as moving any block: one write of its parent and order. Its sub-pages come along for free, because they point at it, not at the old ancestors. The sidebar applies the move optimistically: the row disappears from the old parent and appears under the new one, with the new parent expanded and the row briefly highlighted.
Think first
A user's "Private" section has one page with 3,000 direct sub-pages. What do you change in the sidebar?
Fetch that level in pages of about 100 children, sorted the same way the server stores them, with a "Show more" row at the end, so the first expand returns in one fast request.
Render the sidebar as a virtualized tree: flatten the expanded rows into one list and render only the 30 or so that fit in view, as described in the lesson on virtualization. Keep role="tree" and aria-level, aria-setsize, and aria-posinset on each row so a screen reader still knows where it is in a list it can't fully see.
Check your understanding · Exercise 7
Which statements about the page tree are right? Select all that apply.
Databases: pages with typed properties, seen through views
A Notion database looks like a spreadsheet, but it is built from the same blocks. The database itself is a block that points at a collection record, which holds the schema: a list of properties, each with an id, a name, and a type (text, number, select, multi-select, date, person, checkbox, relation, rollup, formula). Each row is an ordinary page block whose parent is the collection.
A row's property values live in its properties, keyed by property id, and its body is normal blocks. Open a row and you get a full page, because it is one.
A view is a separate collection_view record: a layout (Notion offers table, board, timeline, calendar, list, gallery, and chart), plus filters, sorts, a group-by property, and which properties are visible and how wide. Many views can point at one collection, so "My tasks" as a board and "All tasks" as a table show the same rows. Changing a filter changes the view record, not the data. Notion lets a filter be saved for everyone, which syncs like any other record, or kept just for you.
Where do filters, sorts, and groups run?
The naive client downloads every row and filters in JavaScript. For a 200-row task list that is fine and instant. For a 100,000-row CRM, at roughly 1 KB per row with its properties, it means 100 MB of JSON, tens of seconds on a phone, and a tab that runs out of memory. The server can't do everything either: if each keystroke in a filter box waits 300 ms for a round trip, the view feels sluggish.
Decision Where do filters, sorts, and grouping run?
What we pick: Hybrid by size. Most databases are small, and for them nothing beats querying in memory. Big ones go to the server, which returns row ids in pages; the rows themselves come through the normal record cache. To keep the two engines in agreement, write the filter, sort, and formula evaluator once in TypeScript and run the same module in the browser and on the server, with shared tests.
One collection, many layouts
This design covers the five layouts that shape the client most. Timeline and chart follow the same pattern: a view record with a layout, a query, and its own rendering code loaded on demand.
- Table: rows and property columns, virtualized in both directions, with sticky headers and inline editing. The guide Design a data table covers this layout in depth.
- Board: one column per option of a select or status property. Dragging a card to another column is a property change on that row (
statusbecomesDone); its position inside the column is stored in the view, not the row, because each view orders cards its own way. - Calendar: rows placed by a date property. Query only the visible range (a month is about six weeks of dates) and fetch the next month as the user pages.
- Gallery: cards with a cover image from the page's first image or a file property. Images load lazily with fixed aspect boxes, so nothing shifts.
- List: a light list of titles, cheap to render, good for mobile.
A subtle rule: when a user edits a row so that it no longer matches the filter (sets a task to Done in a "Not done" view), don't remove it at once. A row that vanishes under the user's hand is jarring, and they may want to undo. Keep it in place until the view is refreshed or reopened. In the same spirit, a new row created in a filtered view gets its properties pre-filled from the filters, so it doesn't vanish the moment it is created.
Relations, rollups, and formulas
A relation property stores a list of page ids from another (or the same) database: a task points at its project. A two-way relation writes both sides in one transaction, so the project's "Tasks" list and the task's "Project" never disagree. A rollup reads a property through a relation and aggregates it: the project shows "7 of 12 tasks done". A formula computes a value with a small expression language from the row's own properties; current Notion formulas can also read properties of related pages.
Rollups and formulas are derived data, so the question is when to compute them. For visible rows, the client computes them from records it already has, which gives instant feedback while editing. When a view filters or sorts by a rollup over thousands of related rows, the server computes it, because the client doesn't have those rows. Rollups across big relations can be stale for a second or two after an edit; show the last computed value rather than a spinner.
Check your understanding · Exercise 8
A board view groups 40,000 tasks by Status. Which choices are sound? Select all that apply.
Real-time collaboration: transactions, merging, and presence
So far every edit is a transaction of small operations. Now several people produce them at once. Notion's data model post describes its path in detail, and it is a good base to build on. The client applies a transaction to its local records at once, so the screen updates within milliseconds.
It also puts the transaction in a queue, called TransactionQueue, persisted in IndexedDB or SQLite, and posts it to a /saveTransactions endpoint. The server loads the records involved (the "before"), applies the operations to a copy (the "after"), checks permissions and consistency between the two, and commits or rejects the whole transaction.
Getting the change to other people is a separate path. Every client keeps one WebSocket open to a real-time service Notion calls MessageStore, and subscribes to each record it renders. After a commit, the server tells MessageStore which records have new versions, and MessageStore notifies every subscribed client. Each client sees that its local version is older and asks for the new values with a syncRecordValues request.
Note the shape: the socket carries small "record X is now version 42" notices, and the data comes over normal requests. That keeps the socket light and reuses the permission checks of the API.
syncRecordValues, which reuse the API's permission checks. A reject comes with the records to fall back to.What happens when two edits touch the same thing?
Most concurrent edits touch different records or different fields, and merge for free: Ana checks a to-do while Ben renames the page. The hard cases are two edits to the same field. For a block's type, color, checkbox, or placement, "the last one the server applied wins" is fine: a checkbox can only be one value, and the losing user sees a clear change.
For the text of a block, it is not fine. If Ana and Ben type into the same paragraph and the whole title is one value, one of them loses words. Notion's own post on concurrent editing says this is how its blocks behaved until 2025.
Decision How do we merge concurrent edits to the same block?
What we pick: A text CRDT per text block, last writer wins per property for everything else. Text is the only field where people expect both edits to survive, and blocks keep each CRDT small. This is also where Notion landed: its post on concurrent editing says that in July 2025 it moved block text to a CRDT based on RGA (Replicated Growable Array), with Peritext-style operations so concurrent bold and italic both survive. Its offline post adds that pages made available offline are migrated to this model. See the lessons on CRDTs, operational transformation, and conflict resolution, and the guide Design Google Docs for the text internals.
Blocks add one twist that a single document doesn't have: Enter moves text into another block. If Emma splits a paragraph while Charlie types at its end, Charlie's words should follow the text into the new block. Notion's post handles this by grouping a block's characters into "text slices" that keep their identity when a split moves them, so an edit addressed to the old block can still find its text.
Keeping your own edits stable
While Ana's transaction is in flight, an update from the server for the same block may arrive, made before hers. If the client simply wrote the server value into the store, her edit would flicker away and come back a moment later. So the client keeps two layers: the last confirmed server records, and the queue of its own pending transactions. What the UI shows is always the server records with the pending transactions applied on top.
When a server update arrives, replace the confirmed layer and re-apply the pending ones. When her transaction is acknowledged, drop it from the queue. When it is rejected, drop it, and the screen falls back to the server's truth, with a short message.
- Retries are safe because each transaction has an id, and the server ignores ids it has already committed. A timeout followed by a retry never creates a block twice.
- Order is kept per client: the queue sends transactions one at a time, in order, so a "create block" never arrives after the "type into block" that depends on it.
- Batching keeps request counts sane: keystrokes in the same block within about 300 ms are merged into one transaction before sending.
- Rejections are rare and come from permissions (access removed), validation (a move that would make a cycle), or a deleted parent. The client shows a toast and reloads the affected records.
Presence: who is here and where
Presence is the avatars at the top of the page and the colored carets and block highlights of other people. It is ephemeral: never stored in blocks, never in undo, and lost on reload without harm. Send it on the WebSocket as small messages, { pageId, blockId, offset, selection }, throttled to about 10 per second per user, and drop updates older than the latest.
A remote caret is anchored to a block id and a character inside that block's text (best by the character's CRDT id, so typing before it doesn't shift it), so it stays put when blocks above it are added or removed. With 50 people on a page, show the 5 or so avatars plus "+45", and draw only the carets in view. The lesson on presence and cursors goes deeper.
Check your understanding · Exercise 9
Ana's client has a pending transaction that sets block B's color to red. A server update arrives saying B's color is blue, made by Ben before Ana's change reached the server. What should Ana's client show? Select all that apply.
Offline: a local database and an outbox
There are two different offline problems, and they need different amounts of work. The first is short drops: a train tunnel, a flaky cafe network; the app must keep working for a minute and lose nothing. The second is real offline work: a six-hour flight with a draft to finish. That needs every record of the page on the device beforehand, and a merge on landing that doesn't destroy anyone's work.
Short drops are mostly solved already by the transaction queue. Because it is persisted, edits made without a network wait in IndexedDB or SQLite and go out when the socket reconnects. The UI shows a small "Offline, changes will sync" indicator, never an error per keystroke. On reconnect, the client resubscribes to the records on screen and asks for anything newer than its versions, so it catches up on what others did meanwhile.
What Notion shipped
Notion had used SQLite for years as a best-effort cache of records in its desktop and mobile apps. In August 2025 it shipped Offline Mode, and its engineering post explains what a real guarantee required. A user marks a page "Available offline", and the app downloads every record that page needs. On Business and Enterprise plans it also downloads recent and favorite pages automatically.
A database marked offline downloads its first 50 rows (the post says up to 50 pages of the current view). Notion's help center adds that offline works in the desktop and mobile apps, not the browser. Embeds, forms, and buttons that need a connection are unavailable, and sharing and permission changes wait until you're back online.
- Only complete pages open offline. A page with half its blocks missing is worse than a page that won't open, and it would make merges unpredictable. So the app tracks which pages are fully downloaded and greys out the rest.
- Each offline page records why it is offline. Notion keeps one table of offline pages and another of reasons (toggled, favorite, recent, inherited from a parent database). A page leaves the device only when its last reason goes away, so un-toggling a database doesn't remove a row you also favorited.
- Offline pages stay fresh by push, not polling. The server publishes a message on a channel per page when it changes; the client subscribes for its offline pages and fetches changes. On reconnect, it compares a stored
lastDownloadedTimestampper page with the server's last update time and refetches only newer pages. - Offline pages move to CRDTs. Pages marked offline are migrated to Notion's CRDT data model (the one from the collaboration chapter), so text typed on a plane merges with text typed in the office. The help center adds that text merges automatically, while some non-text changes, like images, may need a quick review.
Storage on the web: IndexedDB or SQLite in the browser
Decision Where does the web client keep records across reloads?
What we pick: SQLite in WASM if the native apps already use SQLite, because sharing one schema and one set of queries across web, desktop, and mobile is worth more than the download. Otherwise IndexedDB is the simpler start; the lessons on IndexedDB and offline-first cover it. Either way, load the storage layer asynchronously so it never delays the first paint.
Notion's post on WASM SQLite is a useful story of what goes wrong. With one SQLite VFS (virtual file system, the layer that maps the database to files), letting every tab's worker write the same database file corrupted data for some users: rows with the same id and different content, which showed comments attributed to the wrong person. The other VFS, the OPFS SyncAccessHandle Pool, had no corruption but could open the database in only one tab at a time.
Their fix works with either: each tab has a dedicated worker, but a SharedWorker elects one active tab, and every tab's queries are routed to that tab's worker. A Web Lock held by each tab tells the SharedWorker when a tab closes, so it can pick a new one. They shipped the SyncAccessHandle Pool VFS because the other one needs cross-origin isolation, which their third-party scripts made impractical.
Two more lessons from the same post apply to any local cache. Downloading the WASM library (a few hundred KB) blocked the first page load and made it slower, so they loaded it fully in the background and let the first page come from the network. And on slow devices, reading from disk could be slower than the API, so page navigations race the local read against the network request and use whichever answers first.
Check your understanding · Exercise 10
A user marks a project page available offline, flies for five hours, and edits it. Which design choices make the landing go well? Select all that apply.
Loading and rendering huge pages
A typical page has 100 blocks and loads in one request. The hard case is the 15,000 block page: a year of meeting notes, or a wiki imported from another tool. Loaded naively, it means about 3 MB of records in one response, a few seconds of JSON parsing and React rendering on a mid-range laptop, 15,000 editable elements in the DOM, and a tab that stutters on every keystroke because layout touches a huge tree.
Load in chunks, first screen first
Notion's post names the API that loads a page: loadPageChunk. It starts from a block (usually the page), descends the content tree, and returns those blocks plus the records they depend on. Before calling it, the client tries local data first: blocks in memory, and the SQLite cache in the apps; only if something is missing does it go to the network. The post doesn't describe how chunks are sized, so the steps below are our design:
- Read what we have locally and render it at once if the page is complete enough to fill the screen.
- Request the first chunk: the page block, its ancestors for the breadcrumb, and its first 100 or so blocks in tree order with their children, plus users mentioned and database schemas used. That is usually 30 to 80 KB compressed.
- Render the first chunk; this is the LCP moment. The rest of the page shows as placeholders of estimated height, so the scrollbar is roughly right.
- Fetch further chunks in the background with the cursor, in order, about 200 blocks at a time, and append them as they arrive. Fetch sooner if the user scrolls toward the end or presses Cmd+End.
- Subscribe to updates for the records as they render, so live edits arrive for what is loaded.
Rendering thousands of editable blocks
Loading in chunks fixes the network. Rendering is the second problem. The usual answer for long lists is virtualization: mount only the rows near the viewport. For editable content it is much harder than for a feed, because the browser's own features assume the content exists.
Decision How do we render a page with 15,000 editable blocks?
What we pick: Mount what is loaded, with sections using content-visibility: auto, and don't window editable pages. This keeps native find, selection, and assistive tech working, and cuts most of the layout cost. Use true windowing only for read-only surfaces (the sidebar tree, database views, search results) where nothing is edited in place. The lesson on virtualization explains the trade-offs of windowing.
- Embeds and media are lazy. Videos, maps, Figma files, and tweets render as a static preview with a fixed aspect box until scrolled near, then mount an
iframewithloading="lazy". A page with 40 embeds would otherwise load 40 third-party apps at once. - Images carry their size. Store width and height on the block so the box is reserved before the image loads; this prevents layout shift (CLS).
- Heavy block types load on demand through the registry: code highlighting, math, diagrams, and database views each come as separate chunks the first time a page uses them.
- Remote edits batch per frame. With 50 people typing, apply incoming records once per animation frame, not once per message.
Check your understanding · Exercise 11
Why is windowed rendering risky for a page of editable blocks? Select all that apply.
Undo and redo across blocks
In a single-user text field, undo restores the previous text. In Notion, one user action may touch ten records (an outdent with younger siblings, a paste of 200 blocks, a drag that creates columns), and other people are editing the same records at the same time. Undo must reverse exactly what this user did, as one step, without undoing anyone else's work.
The transaction is already the right unit. When the client applies a transaction, it also records the inverse: for each operation, the operation that restores the value it overwrote. Setting type from to_do to header has the inverse "set type to to_do", and moving a block has the inverse "put back its old parent and order".
Creating a block has the inverse "mark it deleted": blocks are soft-deleted with alive: false, so undo can bring them back with their ids, comments, and children. The inverses, in reverse order, form the undo entry.
Decision What does pressing Cmd+Z do with other people's edits around?
What we pick: Apply inverses as new transactions, with a check before applying: skip operations whose target no longer exists or was changed by someone else since, and if nothing is left, skip to the next entry. For text, keep undo in the text layer, so undoing your typed words removes those characters only, not a colleague's words typed in between. The guide Design Figma applies the same rule in a design tool.
- Grouping typing. Each keystroke is not its own undo step. Close a typing group after a pause of about 500 ms, at a word boundary after a burst, or when the caret jumps to another block.
- Caret restore. Each entry stores where the caret or block selection was before the action, and undo puts it back there, scrolling the block into view. Undoing something you can't see is confusing.
- Scope. The stack belongs to this tab's session and only holds this user's local transactions; remote updates never enter it. Leaving a page keeps its entries, so returning and pressing Cmd+Z still works.
- Drags and menus. A drag that only previews (Escape) adds nothing. A "Turn into" from the slash menu is one entry, including deleting the typed
/totext.
Check your understanding · Exercise 12
Ana drags block X into a toggle. Then Ben deletes that toggle. Ana presses Cmd+Z. Which behaviors are reasonable? Select all that apply.
Search and publishing to the web
Search across a workspace of a million pages can't run in the browser: the client has a tiny slice of the data, and it must never see pages the user can't access. Notion's post mentions that after each committed transaction, the server schedules indexing of block text for its Quick Find search. So the index lives on the server, and it is updated a few seconds behind edits.
The client's job is to make that server search feel instant. Open the search dialog (Cmd+K or Cmd+P) and show recent pages at once from the local cache, before any request. As the user types, wait about 150 ms after the last key, cancel the previous request with an AbortController, and send the query.
Show results with their breadcrumb path, because three pages called "Notes" are useless without it, and highlight the matched words. Offline, search only what is on the device and say so; Notion's help center says offline pages come first and unavailable ones are greyed out. The dialog is a combobox with a listbox of results, like the slash menu.
Publishing to the web
A page can be published as a public site; Notion calls this Notion Sites, served from a notion.site address or a custom domain. Visitors are not logged in, arrive from search engines and links, and mostly read. That is a different workload from the editor: they need fast first paint, real HTML for crawlers, and almost no JavaScript.
Notion doesn't document how it renders these pages, so the options below are our design. Its help center does describe the knobs: a "Search engine indexing" toggle decides whether a site may appear in search results, paid plans can set the title and description used for SEO and link previews, and indexing can take up to four weeks.
Decision How do public pages render?
What we pick: Server-side rendering with CDN caching, using Cache-Control: public, s-maxage=60, stale-while-revalidate=600 or similar, plus a purge when the page's records change. Readers get HTML from the edge in tens of milliseconds, and edits appear within about a minute. Each public page sets a title, a meta description from the first text block, a canonical URL, and Open Graph tags for link previews. The indexing setting maps to noindex when off. Server HTML makes a page easy to crawl; it doesn't promise when, or how well, it ranks.
Check your understanding · Exercise 13
Which statements about search and public pages are right? Select all that apply.
Architecture: the parts and how data flows
Time to put the pieces together. The client has three layers. The view layer is React: the app shell, sidebar, page renderer with its block registry, database views, and menus. The editing layer sits between the views and the data: the page controller that owns the caret, selection, keys, and drag, and that turns user actions into transactions.
The data layer holds the record store and everything that keeps it in sync with the server and the disk. Each layer only talks to its neighbour, which keeps the editor testable without a network and the sync engine testable without a DOM.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| App shell and router | Layout, sidebar, top bar, routes like /workspace/page-id. | Lets a page swap without reloading the sidebar or sockets. |
| Page renderer and registry | Walks the tree from the page block, mounts one component per block type. | New block types plug in without touching the core; heavy ones load lazily. |
| Page controller | Caret, block selection, keys at block edges, slash menu, drag, paste. | One place that understands the tree; block components stay simple. |
| Operation builder | Turns intents (split, indent, move) into transactions with inverses. | Pure functions, unit tested against a plain record map. |
| Record store | Normalized map of records by table and id; confirmed and pending layers. | Every view reads the same record, so one edit updates all of them. |
| Transaction queue | Persists and sends transactions in order, retries, handles rejects. | Survives reloads and offline; order matters for dependent edits. |
| Sync client | WebSocket, record subscriptions, version notices, presence. | Isolates reconnects and backoff from the UI. |
| Local database | SQLite (WASM in a worker on the web) or IndexedDB: records, outbox, offline set. | Off the main thread; shared across tabs through one writer. |
| Undo manager | Stacks of inverse transactions, typing groups, caret restore. | Reads from the operation builder, writes through the queue like any edit. |
Follow one keystroke through it. The block's editor reports an input to the page controller, which asks the operation builder for a transaction ("insert one character into block B's text"). The builder returns operations and their inverses; the store applies them to the pending layer, which re-renders block B alone, and the undo manager records the inverse.
The queue persists the transaction to the local database and posts it to the server. The server commits it and notifies the real-time service, which tells other clients that B has a new version; they fetch it and re-render B. The figure below shows the full loop, including the first load.
Think first
Why does the page controller build transactions instead of each block component writing to the store directly?
Most edits cross blocks: Enter creates a block and changes another, Tab changes three records, a drag changes placement and maybe creates columns. One owner can make each of these one atomic transaction with one undo entry.
It also means the sync engine, the undo manager, and the server see the same small set of operation kinds, whichever input made them: keyboard, mouse, drag, paste, or a menu.
Interface: the API, the socket, and where each piece of state lives
The API follows the record model. There is no "update paragraph" endpoint; there is one endpoint that loads records, one that saves transactions, one that refreshes records by version, and a socket that says what changed. Names below follow the ones in Notion's post where it gives them; the shapes are ours.
// POST /api/loadPageChunk
type LoadPageChunkRequest = {
pageId: BlockId;
cursor?: string; // from the previous chunk
limit: number; // about 100 blocks for the first chunk
knownVersions?: Record<BlockId, number>; // skip records we have
};
type LoadPageChunkResponse = {
records: {
block: Record<BlockId, { value: Block; role: "editor" | "commenter" | "reader" }>;
collection?: Record<string, Collection>;
notion_user?: Record<string, User>;
};
ancestors: BlockId[]; // for the breadcrumb
cursor: string | null; // null when the page is complete
};
// POST /api/saveTransactions
type SaveRequest = { transactions: Transaction[] };
type SaveResponse =
| { ok: true; versions: Record<BlockId, number> }
| { ok: false; transactionId: string;
error: "forbidden" | "invalid_tree" | "parent_deleted" | "conflict";
records: Record<BlockId, Block> }; // the truth, to fall back to
// POST /api/syncRecordValues
type SyncRequest = { records: { table: "block"; id: BlockId; version: number }[] };
// WebSocket, server to client
type ServerMsg =
| { type: "version"; table: "block"; id: BlockId; version: number }
| { type: "presence"; userId: string; pageId: BlockId;
blockId?: BlockId; offset?: number }
| { type: "access_revoked"; pageId: BlockId };
- Errors are typed.
forbiddencloses the editor for that page;invalid_tree(a cycle or two parents) andparent_deleteddrop the transaction and show the server's records; network errors and 5xx keep it in the queue and retry with exponential backoff and jitter (1 s, 2 s, 4 s, up to about 60 s). - Socket messages are small. Version notices are tens of bytes; data comes over HTTP where it is compressed, cached, and permission checked. On reconnect, the client sends its subscriptions again with known versions.
- Database queries are their own call:
queryCollection({ viewId, filters, sorts, cursor, limit: 50 })returns row ids and a cursor; rows come through the record store.
Where each piece of client state lives matters as much as the API. The rule: anything shared or persisted goes in the record store and the local database; anything that belongs to one screen stays in component or controller state; anything a link should restore goes in the URL.
| State | Lives in | Why |
|---|---|---|
| Blocks, collections, views, users | Record store, mirrored to the local database | Shared by every view; needed offline. |
| Pending transactions | Transaction queue, persisted | Must survive reloads and offline. |
| Current page id and block anchor | URL (/page-id#block-id) | Links to a page or a block must work. |
| Caret, block selection, open menus, drag state | Page controller (memory) | Per tab, changes at 60 Hz, never synced. |
| Expanded sidebar rows | Local storage per user | Restores the sidebar on next visit; harmless if lost. |
| Presence of others | Sync client (memory) | Ephemeral; rebuilt from the socket. |
| Undo stack | Undo manager (memory) | Per session, per user. |
Check your understanding · Exercise 14
The save request for a transaction times out after 10 s, with no response. What should the client do? Select all that apply.
Performance, accessibility, and security
The earlier chapters already made the big performance choices: chunked loading, per-record subscriptions, lazy block types, content-visibility on long pages. This chapter collects the budgets that check them, then covers the two areas a block editor most often gets wrong: keyboard and screen reader support, and safety of content that came from someone else.
Budgets and how to watch them
| Metric | Budget | What protects it |
|---|---|---|
| LCP of a page | Under 2.5 s at the 75th percentile | First chunk only; local cache raced against the network; app shell cached by a service worker. |
| INP while typing | Under 200 ms; key to pixels under 50 ms typical | One block re-renders per keystroke; saving and syncing never block input. |
| Initial JavaScript | Under about 400 KB compressed for the editor shell | Registry with lazy block types, menus, and database views. |
| Long tasks | None over 50 ms during typing | Paste parsing, search index updates, and storage writes off the main thread. |
| Memory | Under about 300 MB for a 10,000 block page | Records stored once by id; offscreen sections skip layout; old pages evicted from memory. |
Measure in the field, not just on a fast laptop. Report LCP and INP with the web-vitals library, tagged with block count and device class, and add custom timings: page open to first chunk rendered, keystroke to save acknowledged, reconnect to caught up. A rise in "save acknowledged" time is often the first sign of a sync problem, long before users complain.
Accessibility
- Real semantics per block. Headings render as
h1toh3, list items inside real lists, to-dos with a real checkbox, toggles as a button witharia-expandedthat controls the children region. Screen reader users navigate by headings and lists, so these must be real. - Each text block is a labelled editable region. Its editable element sits inside the semantic element (the
h2wraps it, so the heading role is kept), hasrole="textbox"witharia-multiline="true"where the type allows line breaks, and gets a short label for non-paragraph types, like "To-do, not done". - Every pointer action has a keyboard path: block menu on Cmd+/, move with Cmd+Shift+Up and Down, indent with Tab, "Move to" for other pages, and Escape to go from text editing to block selection.
- Announce structural changes through one polite live region: "Block moved", "Turned into heading 2", "3 blocks deleted". Do not announce every remote edit; announce that someone joined, and let the user ask who is editing.
- Respect reduced motion: drag previews, highlight flashes, and smooth scrolling become instant when
prefers-reduced-motion: reduceis set.
Security
- Never render stored HTML. Blocks hold rich text as runs and marks, and render with DOM text nodes. Pasted HTML is parsed and mapped to blocks, as the operations chapter showed.
- Check every link. Allow only
https:,http:, andmailto:in link marks, and rejectjavascript:URLs on paste, on save, and on render. - Sandbox embeds. Third-party embeds load in an
iframewith asandboxattribute and only the permissions they need, from an allowlist of providers; unknown URLs become bookmarks. - Content Security Policy. A strict CSP with script nonces limits what an injection could run, and
frame-ancestorsstops other sites from framing the editor. - Clear the device on sign-out and lost access. Delete the local database, the outbox (after a warning if unsent edits remain), and cached records for pages the user can no longer read.
Check your understanding · Exercise 15
A user pastes a link whose URL is javascript:alert(1) into a text block. Which defenses apply? Select all that apply.
Running it in the interview
This problem is too big to cover evenly in an hour, and interviewers know it. What they look for is whether you find the one idea that holds it together (everything is a block with an id, a type, properties, children, and a parent) and then use it to answer every later question. Get to that idea within ten minutes and draw it.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Scope: block types, page and workspace size, collaborators, offline, public pages. Write the numbers down. | A short list of requirements with numbers. |
| 5 to 15 | The block model: flat records, two pointers, normalized store, transactions of operations. | A small page as a tree, and the same blocks as records. |
| 15 to 25 | Rendering and editing: registry, one editable element per block, the page controller; split, merge, indent as transactions. | The page wireframe and the Enter sequence. |
| 25 to 40 | Sync: queue, save, real-time notices; merging per property and text CRDT; ordering with fractional keys; presence. | The architecture boxes and the save sequence with its reject path. |
| 40 to 52 | The interviewer's pick: databases, offline, huge pages, drag and drop, or publishing. | One focused diagram for that area. |
| 52 to 60 | Performance, accessibility, security; what you would measure. | Nothing new; point at the boxes. |
Draw two pictures early and keep pointing at them: the small page as a tree next to its flat records, and the architecture boxes with the store in the middle. Almost every follow-up (a move, a conflict, an offline edit, a slow page) can be answered by tracing an arrow on one of them, which shows the interviewer that your design holds together rather than being a list of features.
Follow-up questions to expect
Think first
How would you build synced blocks, where the same content appears on two pages and edits in one show in the other?
Keep one original block that owns the children, and on the other page place a reference block that points at the original's id. The reference renders the original's children through the same registry and the same record subscriptions, so an edit in either place changes the same records.
Permissions follow the original: a reader of the second page who can't read the original sees a "no access" placeholder. Deleting the reference never deletes the original.
Think first
A customer imports a 50,000 block page and complains that it is slow. Where do you look first?
Measure, then split the problem: time to the first chunk (network and server), time to render the first chunk (main thread), memory and frame time once fully loaded, and keystroke latency. Each points to a different fix.
Likely fixes: smaller first chunk, content-visibility sections, lazy embeds, applying remote changes once per frame, and, for pages this size, offering to split them into sub-pages. Mention that windowing editable blocks has costs to find in page and screen readers before reaching for it.
Think first
Two people edit the same paragraph while one of them is offline for an hour. What does each of them see afterwards?
Text merges through the block's CRDT: both people's words survive, interleaved where they typed. Non-text fields like type or color resolve by last writer wins in the server's order, so one of them sees a change they didn't make.
If the offline user changed the block's type while the other deleted the block, the delete wins and the block goes to trash, where it can be restored. Nothing is silently lost, and a short notice tells the offline user what changed.
Check your understanding · Exercise 16
You have 10 minutes left and haven't covered collaboration. Which moves are strongest? Select all that apply.
Summary
- Everything is a block: a flat record with an id, a type, properties, an ordered list of child ids, and a parent pointer. Pages and database rows are blocks too.
- Content pointers go down for rendering; parent pointers go up for permissions. Every structural edit changes both in one transaction.
- A block registry maps each type to a component and its rules, so new types plug in and heavy ones load lazily; each text block is its own editable element under one page controller.
- User actions become transactions of small operations with stored inverses: applied locally at once, persisted in a queue, saved to the server, and undone by applying the inverse as a new transaction.
- Merge by field: last writer wins for type, properties, and placement; a CRDT for text inside a block; fractional keys with id tie-breaks for sibling order, so a move never duplicates a block.
- Load pages in chunks, first screen first, and render long editable pages with
content-visibilityrather than windowing; lazy-load embeds and heavy block types. - Databases are collections of page rows with typed properties and many views; query in memory when small and on the server when large, with one shared evaluator.
- Offline needs complete pages on the device, a persisted outbox, and CRDT text merges; on the web, SQLite in WASM on OPFS needs one writer across tabs.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- The data model behind Notion's flexibility Blocks, content and parent pointers, transactions, TransactionQueue, MessageStore, and loadPageChunk.
- How we sped up Notion in the browser with WASM SQLite OPFS, a SharedWorker electing one writing tab, and racing the cache against the network.
- How we made Notion available offline Tracking why each page is offline, push updates, and CRDTs for offline pages.
- How Notion handles concurrent editing with CRDTs Last writer wins until 2025, then an RGA-based text CRDT with Peritext-style marks, and text slices for concurrent splits.
- Working offline in Notion (help center) What works offline, on which apps, and how syncing behaves.
- Views, filters, sorts and groups (help center) The database layouts and per-view settings.
- Notion Sites (help center) Publishing pages to the web and the search engine indexing setting.
- Realtime editing of ordered sequences (Figma) Fractional indexing for sibling order.
- Origin private file system (MDN)
- SQLite WASM persistence options The OPFS VFS variants and their trade-offs.
- content-visibility (MDN)
- Element: beforeinput event (MDN)
- Combobox pattern (WAI-ARIA APG) For the slash menu and the search dialog.
Concepts behind it
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- CRDTsConflict-free replicated data types: data structures that every device can edit on its own, offline or online, and that always merge to the same result without a central server deciding the order.
- Real-time collaborationHow several people edit one document at the same time: each person sees their own typing instantly, a server puts everyone's changes in one order, and every copy ends up the same.
- Presence and live cursorsShowing who else is in a shared document and where they are pointing, in near real time: a stream of small, short-lived updates that must stay cheap, smooth, and honest about who has left.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.