cf.completefrontend
My progress
SYSTEM DESIGN · #102

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.

QuestionAnswer we assumeWhat 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 contenteditable was 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.

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?

One document per page (HTML or nested JSON)

The page is one record holding a nested tree or an HTML string.

Saving replaces the record; loading fetches it whole.

Pros

  • Simplest to build and to render.
  • One read loads everything, good for small pages.

Cons

  • Every save rewrites the page; concurrent saves clash.
  • No stable ids for blocks, so links, comments, and moves across pages are hard.
  • Can't load part of a page.

Nested JSON with ids on every node

Same tree, but each node carries an id.

Edits are patches addressed by path, like children[3].children[0].

Pros

  • Blocks have identity.
  • Patches are small.

Cons

  • Paths shift when siblings move, so concurrent patches hit the wrong node.
  • Moving a block to another page is a delete plus an insert of a whole subtree.

Flat block records linked by ids

Each block is its own record: id, type, properties, content (ordered child ids), and parent.

A page is just a block whose type is page; the tree is formed by the id pointers.

Pros

  • Edits touch one or two small records.
  • Ids are stable forever, so anything can reference a block.
  • Load any subtree, page, or chunk on its own; move a block anywhere by changing two pointers.

Cons

  • The tree must be kept consistent: a child's parent and its parent's content must agree.
  • Rendering a page needs many records, so loading must batch them.

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.

ts
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;
};
A block as the client stores it. Rich text is a list of runs, each with its own marks.
On the left, a page as people see it: the page Trip plan holds a header Packing, a to-do Passport, a toggle Flights, and an image map.png; the toggle holds a text block Leave 9:40. On the right, the same page as flat records: p1 page has b1, b2, b3, b5; b1 header in p1; b2 to-do in p1, checked; b3 toggle in p1 has b4; b4 text in b3; b5 image in p1.page: Trip planheader: Packingto_do: Passporttoggle: Flightstext: Leave 9:40image: map.pngwhat you see: a treep1 page has [b1 b2 b3 b5]b1 header in p1b2 to_do in p1, checkedb3 toggle in p1 has [b4]b4 text in b3b5 image in p1what we store: flat records
Each row on the left is one record on the right. Children are listed by id in the parent's 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.

One store, many record types, all keyed by id. A page render reads from several of them.
Record typeKeyWhat it holds
blockblock idEvery block, including pages and database rows.
collectioncollection idA database's schema: property names and types.
collection_viewview idOne view of a database: layout, filters, sorts, groups.
spaceworkspace idWorkspace settings and its top-level pages.
notion_useruser idNames and avatars, for mentions and presence.
discussion and commentidComment 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 wireframe of the page Trip plan at notion.example.com/Trip-plan-p1. A top bar shows the breadcrumb Projects / Trip plan, avatars A and B, and a Share button. On the left, the sidebar tree lists Projects, then Trip plan (open), Budget, and Notes. In the main area, the title Trip plan is followed by blocks: a drag handle beside the header Packing, a checked to-do Passport, an open toggle Flights with a nested text block Leave 9:40 where Ben's caret is, and an empty block where the user typed /to, with a slash menu offering To-do list (highlighted) and Toggle.notion.example.com/Trip-plan-p1Projects / Trip planABShareProjectsTrip planBudgetNotessidebar (lazy)Trip plan::Packing[x] Passportv FlightsLeave 9:40 | Ben/toTo-do listToggleone editor per block
The sidebar and the page are separate trees over the same records. Every block in the page is its own small editor; the handle, the slash menu, and Ben's caret are drawn by the page around them.

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.

tsx
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} />;
}
The registry lets heavy types load on demand and keeps the renderer tiny.

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?

One editable element per text block

Each text block renders its own small contenteditable element, with its own rich text model.

The page controller moves the caret between blocks on arrow keys, Enter, and Backspace.

Pros

  • Fits the block model one to one: a block edit is a title change on one record.
  • A single block's DOM can be replaced (remote edit, type change) without touching others.
  • Blocks can be lazy, unloaded, or non-text (images, databases) with no editor around them.

Cons

  • The browser's native selection stops at each element, so selecting text across blocks must be built by hand.
  • Arrow keys, Enter, and Backspace at block edges need custom code.
  • Spell check and screen reader reading flow are per element.

One editor for the whole page

A single contenteditable root (ProseMirror, Slate, Lexical style) holds the whole page; blocks are nodes in its document.

Changes in the editor are mapped back to block records.

Pros

  • Native selection, copy, and arrow keys work across blocks.
  • One undo stack and one input pipeline.

Cons

  • Every remote change to any block goes through one big editor state; a 10,000 block page is one huge document.
  • Hard to load the page in chunks or skip rendering parts of it.
  • Mapping editor positions back to block records on every keystroke is subtle work.

Read-only blocks plus one floating editor

Blocks render as plain HTML; clicking one mounts an editor on just that block.

Pros

  • Very cheap to render big pages.

Cons

  • A visible flash and lost caret position on every click.
  • Multi-block selection and fast keyboard moves feel broken.

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.

ts
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;
};
Four operation kinds cover every structural edit. Position is given relative to a sibling id, never an index.

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.
A sequence diagram with five columns: User, Page (the page controller), Store, Queue, and Server. The user presses Enter at offset 4 of block b1. The page controller builds one transaction that splits b1 and gives it to the store. The store re-renders b1 and the new block b2. The controller focuses b2 at offset 0. The store hands the transaction to the queue, which persists it and posts it to saveTransactions. The server replies ok with new versions. On the failure path, the server rejects it as forbidden, and the queue tells the store to roll back: drop the transaction and show the server's records.UserPageStoreQueueServerEnter at 4tx: split b1render b1, b2focus b2persist txsaveok, versionsor: forbiddenroll back
The user sees the split before any network call starts. The server's answer only matters if it is a rejection, which rolls the page back to the server's truth.

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.

Caret in

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.

The page as a tree on the left and the last transaction on the right. 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.b1: Trip to Lisbonb2: Passport and IDb3: Flight ticketsb4: Hotel bookingb5: Packing notesNo transaction yetPick a block, then a key.transactions so far: 0each one is one undo step
Each key press is one transaction of a few operations on single records. Outdent keeps the visual order by making the younger siblings children of the outdented block.

Think first

Toggle T has children A, B, C. You outdent B, keeping the visual order. Write the operations.

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 (h1 to header, li to a list block, img to image, pre to code), 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 a code block.
  • 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; DOMParser runs 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?

The HTML Drag and Drop API

Set draggable on the handle, listen to dragstart, dragover, and drop.

Pros

  • Built in; can drag files in from the desktop and blocks out to other apps.
  • The browser draws a drag image for free.

Cons

  • Touch dragging support is uneven across mobile browsers.
  • Little control over the drag image and the drop indicator; dragover fires often but gives poor control of auto-scroll.
  • Hard to animate siblings or build the nesting rules we need.

Pointer events with our own drag layer

On pointerdown on the handle, call setPointerCapture; on pointermove, hit-test blocks and draw our own preview and indicator; on pointerup, commit one move transaction.

Pros

  • Same code for mouse, pen, and touch.
  • Full control of the drop indicator, nesting by horizontal position, and auto-scroll speed.
  • Easy to cancel with Escape and restore.

Cons

  • We write hit-testing and auto-scroll ourselves.
  • Dragging out of the browser window to another app needs the native API anyway.

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.

A state machine for dragging a block. From idle, pointer down on the handle leads to pressed. From pressed, pointer up without moving opens the block menu and returns to idle. Moving more than 4 pixels leads to dragging, which shows a preview and auto-scrolls. Hovering a block leads to over target, which draws the drop line and depth. Pointer up there leads to dropped, which commits one move transaction and returns to idle. From dragging or over target, Escape or releasing with no target cancels back to idle with nothing changed.idlepresseddraggingpreview, auto-scrollover targetdrop line, depthdroppedone move txdownup: menupast 4 pxon blockupEsc or no target: cancelcommit, then idle
Only the dropped state writes anything. Every other path back to idle leaves the store exactly as it was, so there is nothing to undo and nothing to sync.

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_list with two column children, or adds a new column if the target is already in one. When a column empties, remove it; when one column remains, unwrap the column_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?

Content array with relative operations, ordered by the server

The parent keeps content: BlockId[].

Clients send "insert X after Y" and "remove X"; the server applies transactions one at a time and broadcasts the result.

If Y is gone, the server falls back (for example, insert at the end).

Pros

  • Simple and close to the render tree; reading a page's order is one field.
  • This is the shape Notion's data model post describes.

Cons

  • Correctness depends on one server ordering everything; clients that edit offline for hours must replay against a changed list.
  • A move touches two arrays plus the child, so the server must validate that the block ends up in exactly one place.

Fractional index on each child

Each block stores its parent and an order key, a string that sorts between its neighbours, like a0, a0V, a1.

Insert between two keys by computing a key between them; a move sets parent and order together on the moved block only.

Pros

  • A move is one write to one record, so concurrent moves of one block resolve by last writer wins with no duplicates.
  • Inserts at different places never touch each other.

Cons

  • Two clients inserting at the same spot can pick the same key; break ties by block id.
  • Keys grow longer after many inserts in one gap; rebalance them occasionally.
  • Listing children needs an index by parent on the server and a sort on the client.

A sequence CRDT for each children list

A CRDT (conflict-free replicated data type) is a data structure whose copies merge to the same result in any order.

Here each list is a replicated sequence (like the text CRDTs used for characters), with unique ids per element and tombstones (deleted elements kept as markers) for deletes.

Pros

  • Merges any concurrent inserts and deletes without a server, even after days offline.

Cons

  • Moves are not native to most sequence CRDTs: a move is a delete plus an insert, so concurrent moves duplicate the block unless a move-aware design is used.
  • More metadata and more complexity for lists that are usually short.

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
Ben
Server gets first

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.

Three columns: Ana's screen before sync, Ben's screen before sync, and the merged page after sync, each listing blocks with their keys. 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.Ana, before sync0.5 Decisions1 Agenda2 Budget3 ContactsBen, before sync1 Agenda1.5 Decisions2 Budget3 Contactsmerged, with keys1 Agenda1.5 Decisions2 Budget3 Contactsindex ops: D, D, A, BD appears twice, C is gone
With keys, each change writes one block's place, and the last one applied wins, so every block ends up in exactly one place. Index operations replayed against a list that already changed can duplicate or drop blocks.

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.

ts
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) } };
Children are derived by sorting. Ties (same key from two clients) break by id, so every client sorts the same way.

The cases that need rules

Most concurrent edits touch different fields and merge for free. The rules above cover the rest.
Concurrent editsWhat happensWhy it's acceptable
Ana and Ben insert a block at the same spotBoth 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 QThe 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 YX 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 XApplying 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 XBoth 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.

Decision How does the sidebar get its pages?

Load the whole page tree at start

One request returns every page id, title, icon, and parent in the workspace.

Pros

  • Expanding is instant; local search over titles is easy.

Cons

  • Megabytes on every start for big workspaces; slow first load.
  • Stale quickly; every rename anywhere must be pushed to everyone.

Load one level at a time, on expand

Start with the roots (teamspaces, private pages, favorites).

Expanding a page fetches its child pages (ids, titles, icons, and whether each has children).

Pros

  • Tiny first load; cost follows what the user opens.
  • Subscriptions only for visible rows.

Cons

  • A spinner on each first expand.
  • Search must go to the server.

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.

On the left, a sidebar: Projects is expanded and shows Trip plan (with an arrow, children not loaded) and Notes (no children). Archive is expanded and still loading. Wiki is collapsed. On the right, the requests behind it: expanding Projects fetched its 2 child pages with a has-children flag each; expanding Archive fetches the first 100 of 3,000 children with a cursor. A note says expanded rows are saved, so the next start fetches those levels in parallel.v Projects> Trip planNotesv Archiveloading...> Wikichildren(Projects)2 rows + hasChildrenchildren(Archive)100 of 3,000, cursorexpanded rows saved;next start: in parallel
The arrow on Trip plan shows before its children load, thanks to the has-children flag. Big levels arrive in pages, and the rows you had open come back on the next start without a spinner per level.

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.

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?

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.

A database block on the page Sprint points at the collection Tasks, which holds the schema of 5 properties. The collection's rows are page blocks: Fix login, Ship v2, and Write docs. Three views point at the same collection: a Board grouped by Status, a Table filtered to Owner equals me, and a Calendar placed by the Due date.database blockon page Sprintcollection: Tasksschema: 5 propertiesrow: Fix loginrow: Ship v2row: Write docsview: Boardgroup by Statusview: Tablefilter Owner = meview: Calendardates from Duerows are page blocksviews only show and query
The data lives once, in the collection and its row pages. Views only hold how to show and query it, which is why a filter change is a small record edit that syncs like any other.

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?

Always on the client

Download all rows of the collection, then filter, sort, and group in memory.

Pros

  • Instant changes to filters and sorts.
  • Works offline once loaded.

Cons

  • Download and memory grow with the whole database, not with the screen.
  • Slow first open for big databases.

Always on the server

The view sends its filters and sorts; the server returns the first page of matching row ids and a cursor.

Scrolling fetches more pages.

Pros

  • Cost follows the screen: about 50 to 100 rows per request.
  • The server can use indexes and check permissions per row.

Cons

  • Every filter change is a round trip.
  • Local edits must be reconciled with the server's result set.

Hybrid by size

Small collections (under about 1,000 rows): load all, query on the client.

Large ones: query on the server, keep the loaded window in the store, and apply local edits to it until the next server result arrives.

Pros

  • Instant for the common case, safe for the extreme case.
  • Same view code; only the data source changes.

Cons

  • Two query engines must agree on the meaning of every filter, sort, and formula.
  • The switch point needs tuning and testing on both sides.

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 (status becomes Done); 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.

A sequence diagram with Ana, the Server, the Realtime service, and Ben. Ben subscribes to block b7. Ana posts saveTransactions; the server replies ok with b7 at version 42. The server tells Realtime that b7 is now version 42, and Realtime tells Ben that b7 changed. Ben calls syncRecordValues for b7 and the server returns the new value. Later Ana moves b7 under b9, which would create a cycle, so the server rejects it and returns the current records.AnaServerRealtimeBensubscribe b7save txok, b7 v42b7 is v42b7 changedsync b7b7 valuemove b7reject: cycle
The socket carries only small notices like "b7 is now v42". Data travels over normal requests such as 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?

Last writer wins on every field, including text

Each operation sets a whole field; the server's order decides the winner.

Pros

  • Simplest; one rule for everything.
  • Fine when two people rarely type in the same block at the same time; Notion's blocks worked this way until 2025.

Cons

  • Concurrent typing in one block loses one person's words.
  • Offline edits to text overwrite everything typed online meanwhile.

Operational transformation for text, last writer wins for the rest

Text edits are inserts and deletes at offsets; the server transforms concurrent ones against each other in its order.

Pros

  • Small messages; proven in Google Docs.

Cons

  • Needs the server in the loop for every merge; long offline sessions are hard to transform.
  • Transform functions for rich text marks are tricky to get right.

A text CRDT per block, last writer wins for the rest

Each text block's content is a sequence CRDT: every character has a unique id, so inserts and deletes from any replica merge in any order.

Type, properties, and placement stay simple last-writer-wins values.

Pros

  • Concurrent typing in one block keeps everyone's words, online or after hours offline.
  • Merges can happen on any client, not just the server.

Cons

  • More metadata per character; needs compaction.
  • Two data paths: CRDT updates for text, plain operations for the rest.

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.

A state machine for one transaction. It starts pending: applied locally, stored in the queue on disk. When online and first in line, it becomes sending. From sending, an ok response leads to acked, and it is dropped from the queue. A reject leads to rejected: the transaction is dropped and the screen shows the server's records. A timeout or going offline leads to waiting, with backoff and jitter, and then a retry goes back to sending with the same id. A note says the UI shows server records plus pending transactions.pendingin queue, on disksendingone at a timeackeddrop from queuerejectedroll back, toastwaitingbackoff + jitteronlineokrejecttimeout, offlineretry, same idUI = confirmed+ pending txs
A transaction leaves the queue only through acked or rejected. Timeouts never discard it, and retries reuse its id, so the server can ignore a duplicate.
  • 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 lastDownloadedTimestamp per 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?

IndexedDB as a key-value store

One object store per record table, keyed by id; the outbox in another store.

Pros

  • Built into every browser, works in every tab and in the main thread.
  • No extra download.

Cons

  • Queries beyond key lookups (all children of a parent, all offline pages for a reason) need hand-made indexes.
  • Many small transactions are slow on some browsers.

SQLite compiled to WebAssembly, stored in OPFS

Run the official sqlite3 WASM build in a Web Worker, persisting to the Origin Private File System.

The same schema and queries as the desktop and mobile apps.

Pros

  • Real SQL and indexes; one data layer across platforms.
  • Notion reports 20 percent faster page navigation on the web after adopting it, and 28 to 33 percent in regions with slow API responses (Australia, China, India).

Cons

  • A few hundred KB of WASM to download.
  • OPFS gives little help with many tabs writing at once; that needs coordination.

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.

Three tabs, A, B, and C, each send their storage queries to one SharedWorker, which picks an active tab. Only the active tab's dedicated worker, Worker A, opens the SQLite database file in the origin private file system and runs the queries. Worker B, belonging to another tab, stays idle. A note says each tab holds a Web Lock, so the SharedWorker learns when a tab closes and can pick a new active one.BrowserTab Amain threadTab Bmain threadTab Cmain threadSharedWorkerpicks active tabWorker BidleWorker Aactive: runs SQLOPFS fileSQLite databasea Web Lock per tabreveals closed tabs
Many tabs, one writer. Routing every query through the active tab's worker is what stopped the corruption that came from several tabs writing the same file at once.

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:

  1. Read what we have locally and render it at once if the page is complete enough to fill the screen.
  2. 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.
  3. 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.
  4. 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.
  5. Subscribe to updates for the records as they render, so live edits arrive for what is loaded.
A timeline of opening a 15,000 block page, from 0 to 2 seconds. The first chunk request takes until about 0.45 s. Rendering the first screen finishes at about 0.75 s, which is the LCP. Chunk 2 loads from 0.75 to 1.15 s and chunk 3 from 1.15 to 1.55 s, in the background. From 0.75 s on, the client subscribes to the loaded records and receives live edits.LCP about 0.75 snetworkchunk 1renderrenderbackgroundchunk 2chunk 3socketsubscribe, live edits00.5 s1 s1.5 s2 s
The user can read and type at about 0.75 s, long before the page is complete. Later chunks fill placeholders whose estimated heights keep the scrollbar roughly right.

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?

Mount everything that is loaded

Every block is in the DOM as soon as its chunk arrives.

Pros

  • Native find in page (Cmd+F), text selection, and screen reader browsing all work.
  • No scroll jumps from height estimates.

Cons

  • Thousands of elements cost memory, style, and layout time; typing slows as the page grows.

Windowed rendering (virtualization)

Mount only blocks within one or two screens of the viewport; replace the rest with spacers of measured or estimated height.

Pros

  • Constant DOM size whatever the page length.

Cons

  • Cmd+F can't find unmounted text, so the app needs its own find.
  • Selections and the caret can't live in unmounted blocks; a multi-block selection across unmounted ones must be modeled, not native.
  • Height estimates cause scroll jumps, and screen readers see a page that changes under them.

Mount everything, but let the browser skip offscreen work

Group blocks into sections of about 50; give each section content-visibility: auto and contain-intrinsic-size: auto 1200px.

The browser skips style, layout, and paint for offscreen sections but keeps them in the DOM.

Pros

  • Find in page, selection, and accessibility keep working, because the DOM is all there.
  • Rendering work drops to about what is on screen.

Cons

  • The DOM still costs memory per element.
  • Offscreen sizes are estimates until first shown, so the scrollbar may shift a little.

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 iframe with loading="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?

Restore a snapshot of the page

Keep copies of the page before each action and put the old copy back.

Pros

  • Trivial to build for one user.

Cons

  • Wipes out everything collaborators did since then.
  • Copies of big pages are expensive to keep.

Apply the inverse as a new transaction

Undo builds a new transaction from the stored inverses and sends it like any other edit.

Redo stores the inverse of that undo.

Pros

  • Only this user's changes are reversed; others' edits stay.
  • Undo syncs, retries, and merges through the normal path.

Cons

  • An inverse can conflict with later edits: the block may have been deleted or moved by someone else.
  • Text undo must go through the text CRDT, not a plain set of the old string.

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.

On the left, Ana's undo stack, newest on top: move X, with the inverse move X back to its old parent; typed milk, with the inverse delete those characters; turn into to-do, with the inverse back to text. On the right, what happened on the page in order: 1 Ana turns a block into a to-do, 2 Ana types milk, 3 Ben types eggs, 4 Ana moves X into toggle T, 5 Ben deletes T. Below, Ana presses Cmd+Z, which sends a new transaction that moves X back, and Ben's words and his delete stay untouched.Ana's undo stack, newest on topmove X: move backtyped milk: delete itturn into: back to textwhat happened on the page1 Ana: turn into to_do2 Ana: types milk3 Ben: types eggs4 Ana: moves X into T5 Ben: deletes Tpops the topCmd+Znew tx: move X backBen's work stayseggs and the delete untouched
The stack holds only Ana's actions, each with its inverse. Undo builds a new transaction from the top entry, so it syncs like any edit and leaves Ben's work alone.
  • 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 /to text.

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?

The editor app in read-only mode

Serve the same app bundle; load blocks with the API; render client side with editing turned off.

Pros

  • One code path; every block type works automatically.

Cons

  • Megabytes of editor JavaScript for a reader.
  • Link preview bots don't run scripts, and search engines render scripts later and less reliably than plain HTML; LCP is slow on phones.

Server-side rendering with CDN caching

A render service loads the page's blocks, renders the same block components to HTML on the server, and caches the result at the CDN.

A small script hydrates only interactive parts: toggles, database views, and copy buttons.

Pros

  • Fast LCP from the CDN edge; complete HTML for crawlers.
  • Shares block components with the editor through the registry.

Cons

  • Cache invalidation when the page is edited.
  • Block components must be safe to render on a server.

Static export on publish

Build HTML files for the page tree when the user clicks Publish, and serve them as files.

Pros

  • The cheapest and fastest to serve.

Cons

  • Edits don't show until the next publish, unless the export reruns on every edit.
  • Database views with filters need a server anyway.

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.

A reader or crawler requests a public page from the CDN. On a cache miss, the CDN asks the SSR render service, which loads the page's blocks from the records store and renders them with the same block registry, returning HTML with meta tags and a small script for toggles and database views. When the author saves a transaction, the records change and the CDN copy is purged. Cache hits are served from the edge in tens of milliseconds.Reader, crawlerCDNs-maxage, SWRSSR renderblock registryEditorauthor saves a txRecordspage blocksHTML + metasmall JSGETmissload blockssavepurgehit: tens of ms from the edge
Readers never download the editor. The only coupling to editing is the purge: a save changes the records, and the next request renders fresh HTML.

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.

The client architecture. In the browser tab, the top row holds the App shell (sidebar, routes), the Renderer (block registry), and the Controller (caret, keys, drag). The Renderer passes input to the Controller, which asks the Op builder for transactions with inverses. The Op builder applies them to the Store, which holds confirmed and pending records and feeds the Renderer. Undo sends inverse transactions to the Store. The Store hands transactions to the Tx queue, which persists them through the SQLite worker on OPFS and sends them through the Sync client. The Sync client talks to the API for saves and loads and to the Realtime service for notices, and writes fresh records back into the Store.Browser tabApp shellsidebar, routesRendererblock registryControllercaret, keys, dragUndoinverse stacksStoretwo layersOp buildertx + inversesSQLiteworker, OPFSTx queueordered, retriedSync clientHTTP, socketServersAPIsave, loadRealtimenoticesonly the store is shared
Every arrow into the Store is a transaction or fresh records; every arrow out feeds the screen or the network. The Store is the only part everyone shares.
Nine parts. Only the record store is shared by everything; the rest have one job each.
PartResponsibilityWhy the boundary is here
App shell and routerLayout, sidebar, top bar, routes like /workspace/page-id.Lets a page swap without reloading the sidebar or sockets.
Page renderer and registryWalks 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 controllerCaret, block selection, keys at block edges, slash menu, drag, paste.One place that understands the tree; block components stay simple.
Operation builderTurns intents (split, indent, move) into transactions with inverses.Pure functions, unit tested against a plain record map.
Record storeNormalized 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 queuePersists and sends transactions in order, retries, handles rejects.Survives reloads and offline; order matters for dependent edits.
Sync clientWebSocket, record subscriptions, version notices, presence.Isolates reconnects and backoff from the UI.
Local databaseSQLite (WASM in a worker on the web) or IndexedDB: records, outbox, offline set.Off the main thread; shared across tabs through one writer.
Undo managerStacks 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.

Data flow for the page screen. The CDN sends the app shell and JavaScript to the Blocks UI. The API returns page chunks to the Sync client, which writes records into the Store, and the Store re-renders blocks by id in the UI. A local read from the SQLite cache races the API inside the Sync client. On edits, the Controller writes pending transactions into the Store, the Store hands them to the Tx queue, which persists them in the SQLite cache and sends them through the Sync client to the API. The Realtime service sends version notices to the Sync client, which then syncs the changed records.CDNshell, JSBlocks UIre-render by idAPIchunks, savesSync clientHTTP, socketStorerecordsRealtimeversion noticesTx queuein orderControllerkeys to txSQLite cacherecords, outboxapp shell and JSchunksaverecordsby idpendingsendnoticelocal read races API
Two loops share one store. Reads flow from the API or the local cache, through the sync client, into the store and up to the blocks; writes flow from the controller through the store and queue to the API, and come back to other clients as notices.

Think first

Why does the page controller build transactions instead of each block component writing to the store directly?

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.

ts
// 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
};
Loading a page in chunks. The cursor is opaque to the client.
ts
// 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 };
Saving and syncing. Errors name the transaction, so the queue knows what to drop.
  • Errors are typed. forbidden closes the editor for that page; invalid_tree (a cycle or two parents) and parent_deleted drop 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.

StateLives inWhy
Blocks, collections, views, usersRecord store, mirrored to the local databaseShared by every view; needed offline.
Pending transactionsTransaction queue, persistedMust survive reloads and offline.
Current page id and block anchorURL (/page-id#block-id)Links to a page or a block must work.
Caret, block selection, open menus, drag statePage controller (memory)Per tab, changes at 60 Hz, never synced.
Expanded sidebar rowsLocal storage per userRestores the sidebar on next visit; harmless if lost.
Presence of othersSync client (memory)Ephemeral; rebuilt from the socket.
Undo stackUndo 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

MetricBudgetWhat protects it
LCP of a pageUnder 2.5 s at the 75th percentileFirst chunk only; local cache raced against the network; app shell cached by a service worker.
INP while typingUnder 200 ms; key to pixels under 50 ms typicalOne block re-renders per keystroke; saving and syncing never block input.
Initial JavaScriptUnder about 400 KB compressed for the editor shellRegistry with lazy block types, menus, and database views.
Long tasksNone over 50 ms during typingPaste parsing, search index updates, and storage writes off the main thread.
MemoryUnder about 300 MB for a 10,000 block pageRecords 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 h1 to h3, list items inside real lists, to-dos with a real checkbox, toggles as a button with aria-expanded that 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 h2 wraps it, so the heading role is kept), has role="textbox" with aria-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: reduce is 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:, and mailto: in link marks, and reject javascript: URLs on paste, on save, and on render.
  • Sandbox embeds. Third-party embeds load in an iframe with a sandbox attribute 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-ancestors stops 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.

MinutesWhat to doWhat to draw
0 to 5Scope: block types, page and workspace size, collaborators, offline, public pages. Write the numbers down.A short list of requirements with numbers.
5 to 15The 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 25Rendering 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 40Sync: 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 52The interviewer's pick: databases, offline, huge pages, drag and drop, or publishing.One focused diagram for that area.
52 to 60Performance, 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?

Think first

A customer imports a 50,000 block page and complains that it is slow. Where do you look first?

Think first

Two people edit the same paragraph while one of them is offline for an hour. What does each of them see afterwards?

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-visibility rather 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next