0306090120150180210240270300330km/h
0 km/h
ADIT JANGID

Deconstructing Notion: How Their Block-Based Editor Works

An engineering breakdown of block-based editors, detailing data structures, rendering engine strategies, real-time collaboration with CRDTs, and undo/redo stacks.

Deconstructing Notion: How Their Block-Based Editor Works

Introduction

Traditional rich text editors (like TinyMCE or CKEditor) operate on a single monolithic HTML document structure. When you change text formatting, they mutate raw DOM nodes. This legacy design makes complex operations—such as dragging and dropping paragraphs, nesting columns, or building collaborative multiplayer sync engines—exceptionally difficult.

Notion revolutionized this space by treating documents not as HTML pages, but as directed acyclic graphs of block objects.

In this architectural breakdown, we analyze how a modern block-based editor works under the hood, exploring document data structures, rendering logic in React, and conflict resolution using Y.js.


The Block Data Structure

In a block editor, every element (a heading, a paragraph, an image, a checklist item, or a layout column) is represented as a single block. Each block has a type, metadata, content properties, and references to its children.

Here is a typical JSON schema representing a structured document:

{
  "document": {
    "id": "doc-9912",
    "title": "Engineering Roadmap",
    "rootBlockId": "block-root-001"
  },
  "blocks": {
    "block-root-001": {
      "id": "block-root-001",
      "type": "page",
      "properties": {
        "title": "Main Wiki Page"
      },
      "children": ["block-h1-001", "block-p-001", "block-col-001"],
      "parentId": null
    },
    "block-h1-001": {
      "id": "block-h1-001",
      "type": "heading_1",
      "properties": {
        "text": "System Architecture Overview"
      },
      "children": [],
      "parentId": "block-root-001"
    },
    "block-p-001": {
      "id": "block-p-001",
      "type": "paragraph",
      "properties": {
        "text": "This document outlines our transition towards a decentralized micro-frontend layout."
      },
      "children": [],
      "parentId": "block-root-001"
    },
    "block-col-001": {
      "id": "block-col-001",
      "type": "column_group",
      "properties": {},
      "children": ["block-col-child-a", "block-col-child-b"],
      "parentId": "block-root-001"
    },
    "block-col-child-a": {
      "id": "block-col-child-a",
      "type": "column",
      "properties": {
        "width": 50
      },
      "children": [],
      "parentId": "block-col-001"
    },
    "block-col-child-b": {
      "id": "block-col-child-b",
      "type": "column",
      "properties": {
        "width": 50
      },
      "children": [],
      "parentId": "block-col-001"
    }
  }
}

This flat, map-based dictionary structure allows for fast updates. Rather than traversing an entire deep nested tree when updating a single character, you look up the block directly by its unique id in $O(1)$ time complexity.


React Dynamic Rendering Engine

To display the blocks, the editor maps each block type to a specific React Component. All edit events (typing, backspacing, hitting enter, changing block styles) bubble up to a unified state manager.

Here is a simplified React implementation demonstrating this dynamic rendering pattern, including input mutation handling:

import React, { useState } from 'react';

type BlockType = 'heading_1' | 'paragraph' | 'todo';

interface Block {
  id: string;
  type: BlockType;
  content: string;
  checked?: boolean;
}

interface EditorProps {
  initialBlocks: Block[];
}

export const BlockEditor: React.FC<EditorProps> = ({ initialBlocks }) => {
  const [blocks, setBlocks] = useState<Block[]>(initialBlocks);

  const updateBlockContent = (id: string, newText: string) => {
    setBlocks((prev) =>
      prev.map((b) => (b.id === id ? { ...b, content: newText } : b))
    );
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>, index: number, block: Block) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      // Insert a new paragraph block below the current one
      const newBlock: Block = {
        id: Math.random().toString(36).substr(2, 9),
        type: 'paragraph',
        content: '',
      };
      const updated = [...blocks];
      updated.splice(index + 1, 0, newBlock);
      setBlocks(updated);

      // Focus the new block in the next event loop tick
      setTimeout(() => {
        const nextEl = document.getElementById(`editable-${newBlock.id}`);
        nextEl?.focus();
      }, 0);
    }

    if (e.key === 'Backspace' && block.content === '' && index > 0) {
      e.preventDefault();
      const updated = blocks.filter((b) => b.id !== block.id);
      setBlocks(updated);

      // Focus the previous block
      const prevBlock = blocks[index - 1];
      setTimeout(() => {
        const prevEl = document.getElementById(`editable-${prevBlock.id}`);
        prevEl?.focus();
        // Place cursor at the end
        if (prevEl) {
          const range = document.createRange();
          const sel = window.getSelection();
          range.selectNodeContents(prevEl);
          range.collapse(false);
          sel?.removeAllRanges();
          sel?.addRange(range);
        }
      }, 0);
    }
  };

  return (
    <div className="max-w-2xl mx-auto py-8 px-4 space-y-2">
      {blocks.map((block, index) => (
        <div key={block.id} className="group relative flex items-start">
          {block.type === 'heading_1' && (
            <h1
              id={`editable-${block.id}`}
              contentEditable
              suppressContentEditableWarning
              onInput={(e) => updateBlockContent(block.id, e.currentTarget.textContent || '')}
              onKeyDown={(e) => handleKeyDown(e, index, block)}
              className="text-3xl font-extrabold text-gray-900 w-full outline-none focus:border-b focus:border-blue-300"
            >
              {block.content}
            </h1>
          )}

          {block.type === 'paragraph' && (
            <p
              id={`editable-${block.id}`}
              contentEditable
              suppressContentEditableWarning
              onInput={(e) => updateBlockContent(block.id, e.currentTarget.textContent || '')}
              onKeyDown={(e) => handleKeyDown(e, index, block)}
              className="text-base text-gray-700 w-full outline-none focus:border-b focus:border-blue-300"
            >
              {block.content}
            </p>
          )}
        </div>
      ))}
    </div>
  );
};

Real-Time Collaboration with CRDTs

When multiple users edit a document simultaneously, we need a way to resolve conflict. Operational Transformation (OT)—used in Google Docs—requires a centralized server to resolve operation order. Notion uses a hybrid approach, but many modern editors leverage Conflict-free Replicated Data Types (CRDTs).

Using Y.js, we can synchronize our block tree across users peer-to-peer or via WebSockets with guarantee of eventual consistency.

Below is the conceptual wrapper code mapping our block editor store to a shared Y.js instance:

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

export class CollabManager {
  private doc: Y.Doc;
  private sharedBlocks: Y.Map<any>;
  private provider: WebsocketProvider;

  constructor(roomName: string, onUpdate: (blocks: any) => void) {
    this.doc = new Y.Doc();
    
    // Connect to the shared WebSocket server
    this.provider = new WebsocketProvider(
      'wss://your-yjs-websocket-server.com',
      roomName,
      this.doc
    );

    // Get or create a top-level shared map representing block states
    this.sharedBlocks = this.doc.getMap('blocks');

    // Subscribe to incoming updates from other users
    this.sharedBlocks.observe((event) => {
      const updatedBlocks = this.sharedBlocks.toJSON();
      onUpdate(updatedBlocks);
    });
  }

  public updateBlock(blockId: string, blockData: object) {
    this.doc.transact(() => {
      const currentBlock = this.sharedBlocks.get(blockId);
      this.sharedBlocks.set(blockId, {
        ...currentBlock,
        ...blockData,
        updatedAt: Date.now(),
      });
    });
  }

  public insertBlock(blockId: string, newBlock: object) {
    this.sharedBlocks.set(blockId, newBlock);
  }

  public destroy() {
    this.provider.destroy();
    this.doc.destroy();
  }
}

Performance Optimizations at Scale

As a user builds a long wiki page, the document can grow to thousands of blocks. Rendering thousands of dynamic contentEditable nodes in React slows down the page. To keep interaction response times lightning fast, we apply two optimizations:

  1. Window Virtualization: We only render the blocks that are visible within the browser viewport (using @tanstack/react-virtual). Blocks outside the viewport are represented by simple spacer divs.
  2. Debounced Global Syncing: While local keystrokes are applied to the local state instantly, network mutations and history snapshots (undo/redo states) are debounced by 250ms to prevent heavy browser execution and packet storms.

Summary

Deconstructing Notion's editor reveals that building high-performance document tools requires treating data as structured schemas first and UI components second. Moving away from standard monolithic HTML fields to discrete, independent block graphs makes collaborative features, modular rendering, and layout management straightforward.

notioneditorengineeringcrdtyjs

Related Posts

Interested in working together?

Let's translate complex software and automation problems into clean, high-performance systems.