How Online Documents Survive 10,000 Blocks: Flat Records, Virtual Scrolling, and One Shared Editor
🔥 Let's Talk About How the Block Architecture of Online Documents Is Implemented
By 尤水就下
Foreword
For frontend developers, online documents can be considered one of the ceilings of frontend technology. When I first entered the frontend world, I felt this was something I could only look at from afar. I often wanted to join such a team, but worried I wasn't good enough. In recent years I've been lucky enough to take part in some document development work, so I want to use this chance to write something down and leave a trace. Even though AI is here now, I still insist on hand-writing it ✍🏻.
It wasn't until I first worked with documents that I learned about Block architecture. So if this is your first time hearing about Block architecture, that's normal; after all, we usually don't need it 😄. But knowing a bit more lets you show off better next time. For example, recently a beautiful, kind, and generous girl in our department asked me about an article she'd read on dropdown list optimization, and asked what there was to optimize 😦. I said, sure, in most cases there's nothing to optimize, but once the data volume gets large, problems appear, and it can freeze 🫠, because there are too many elements and the browser can't render them all at once. And thinking bigger, if each item in the list isn't limited to text but can be rich text, images, and other formats, doesn't that start to look a lot like a document? Then I recommended this article to her. I don't know if she read it, hahahahaha.
What Is a Block
Let's get straight to the point and explain what Block architecture is. First, look at this image:
The image above is the basic form of a document. We call a document a Page, and a Page is made up of multiple Blocks (text/image/table/chart/code/third-party components can all be seen as a Block). Intuitively, its basic data structure looks roughly like this:
{
"type": "page",
"children": [
{ "type": "text", "text": "This is text" },
{ "type": "image", "url": "https://example.com/xxx.png" },
{ "type": "chart", "data": "{ xxx: custom data }" },
{ "type": "code", "data": "{ xxx: custom data }" },
]
}
If we treat the whole document as just one huge nested JSON, that's obviously not very reasonable, and you'll run into many problems. Here are a few:
- In multi-person collaboration, how do you precisely describe "who changed which part"?
- When modifying a deep node, how do you locate it in a big tree, and how do you handle moving nodes?
- If a document has tens of thousands of blocks, can you only load and render them all at once?
- If the document data is too large, how do you compress it?
- If the document hasn't finished downloading, why can the first screen already be seen, and even edited?
- When the same content appears in multiple places, can you only copy the data?
- Does every character typed re-render the whole document?
- When an image's height changes after loading, why doesn't the page suddenly jump?
- How do you guarantee data consistency and stability during collaboration?
- ...
Behind these problems lie a whole set of systems: data storage, runtime tree, rich text input, collaborative editing, virtual layout, plugin extension, and more. Enough talk, let's start optimizing 🛫.
Flattening the Data
Although the big JSON above is intuitive, if the data is too large it slows down first-screen loading, because you have to wait for the whole JSON to arrive before parsing, and it's hard to parse it in pieces. If you change a deeply nested block, modifying deep nodes is troublesome. During collaboration, if two people change the same block or parent-child blocks, it's hard to handle and easy to conflict. So let's first make a simple change to the data structure, turning it into this 👇🏻:
{
"root": "page",
"recordMap": { // flatten every block; this is important, record is the data entity
"page-1": {
"id": "page-1",
"type": "page",
"children": ["text-1", "image-1", "text-2"] // expresses hierarchy and order
},
"text-1": {
"id": "text-1",
"type": "text",
"parentId": "page-1",
"text": "First paragraph"
},
"image-1": {
"id": "image-1",
"type": "image",
"parentId": "page-1",
"url": "https://example.com/xxx.png"
},
// ...
}
}
What's different from before? We give every block a stable id and put all blocks into a recordMap (this recordMap can be split). In plain words, we break the document tree into cards and string them together with ids.
| Benefit | Why |
|---|---|
| Local updates | Modifying one Block only requires updating one Record's data. |
| Independent collaboration | Every edit operation can point precisely to a path in one Record's data. |
| Paginated loading | You don't have to wait for the whole tree; Records can be added in batches. |
| Stable moves | Moving a Block mainly modifies the parent node's children order. |
| Content reuse | The same Record data can be shared across multiple runtime positions. |
Of course, ids and parent references add some storage overhead. But this is a typical example of trading space for capability: in exchange you get offline creation, fine-grained collaboration, local caching, and stable references.
Now we can answer the first-screen loading question. If a Page has ten thousand direct Blocks, the Page's children usually stores those ten thousand ids and maintains their order. But that doesn't mean the first screen has to return ten thousand complete Block payloads at once. The common approach is structure first, content later, like this:
// A large document with a bunch of Blocks
Page.children = [A, B, C, ... , Z]
// Actual loading
1. First-screen real data: A, B, C, so only load these three blocks' data
2. Not arrived yet: D~Z, create Pending Blocks first
3. Later pagination: gradually replace Pending Blocks with real data
Note that a Page only stores its direct children. If A is a container, A's descendants are stored in A's own children, and are not stuffed into Page again.
Next, we can split a Block into internal and external parts. The Block's internal part mainly manages its own component data and rendering; the external part is mainly about where the Page places the Block in the document. Of course, a Block also has a common container that handles common properties and methods, such as the Block's width, height, and dragging. So when loading a document, the Block's external information and the container's own width/height are the two basic pieces of information that are necessary. As for the Block's internal data and rendering, lazy loading can be used. That is, only when a Block appears near the viewport do you dynamically fetch the Block's internal data. The same logic applies when jumping to another part of the page. This keeps the document itself very lightweight; the Block's internal data is the real bulk.
Usually when loading a document, we first request a snapshot of the document, then parse the snapshot into memory state. Because we have each Block's order and size, we can do page layout directly. Even though the Blocks are empty inside, the overall frame is already there. After that, each Block fills in data and renders when necessary, without affecting the rest of the page 👍🏻.
Let me briefly mention what the data structure of a heavy Block like Table looks like. Tables are a bit special: they don't rely on children to describe table content. Instead, they look like this:
{
"type": "table",
"rows": ["r1", "r2"], // rows
"cols": ["c1", "c2"], // columns
"cell": {
// find the specific cell data by blockId
// merge indicates merged cell info
"r1c1": { "blockId": "cell_A", "merge": { "row": 1, "col": 1 } },
"r1c2": { "blockId": "cell_B", "merge": { "row": 1, "col": 1 } },
"r2c1": { "blockId": "cell_C", "merge": { "row": 1, "col": 1 } },
"r2c2": { "blockId": "cell_D", "merge": { "row": 1, "col": 1 } },
}
}
Actually the core idea is the same as other Blocks: pull the specific cell data out.
Chunked Loading
With the data structure above, we can do on-demand/chunked/batched loading for large document data. For the server, each request only needs to return a portion of the data, not a full push. For example:
{
"records": { // only read 20 block records
"block-1": {},
"block-2": {},
// ...
"block-20": {},
},
"hasMore": true,
"cursor": "next-cursor-token" // for fetching the next page
}
The cursor above is like a pointer to the next piece of data. The client doesn't need to parse the cursor; it just sends it back unchanged on the next request:
Request document
→ returns first page + hasMore=true + cursor-2
→ request cursor-2
→ returns second page + hasMore=true + cursor-3
→ request cursor-3
→ hasMore=false
At this point you might think 🤔, what if one block's data is too large? It would drag down the other blocks in the same request. The handling is actually simple and crude: filter it out. The server doesn't return those complex blocks' data in the response, but returns another field, like ignore_blocks, indicating which heavy blocks were ignored this time. The frontend then pulls that heavy data separately.
Everything above is about chunked data fetching. In fact, after this data arrives, it isn't immediately converted into memory state, because some data may not be used. So we can convert only the necessary data into memory state, and process the rest during idle time or when jumping to the target area. Remember:
- Downloaded data is not equal to data applied to memory
- Data applied to memory is not equal to data rendered
Virtual Scrolling
This is the part the pretty colleague asked about in the most detail.
As everyone knows, if a page has ten thousand components to render, we can't possibly display them all. That's where the concept of virtual scrolling comes in. The core of virtual scrolling is simple: only render components near the viewport, and use empty components in place of the rest. As the user keeps scrolling, the components that need to be shown in the viewport are recalculated and re-rendered. Long documents follow the same idea. Let's look at the image below to understand 👇🏻:
Suppose a document has ten thousand Blocks, and the current screen only sees Blocks 500 to 520. What actually needs to be mounted is usually:
Top placeholder: cumulative height of Blocks 0~489
Upper Buffer: Blocks 490~499
Viewport: Blocks 500~520
Lower Buffer: Blocks 521~530
Bottom placeholder: cumulative height of Blocks 531~9999
The Buffer's purpose is to extend a bit above and below the viewport, so data or components can be loaded in advance and white screens during fast scrolling are reduced. How do you tell whether a block is in the viewport? It's simple, just a calculation like this:
// current block position
blockTop
blockBottom = blockTop + blockHeight
// is it visible
visible =
blockTop <= viewportBottom &&
blockBottom >= viewportTop;
Where does blockTop come from? When we first get the document data, we build a layout tree called LayoutTree. This LayoutTree has each block's top and height values. But some blocks don't have a concrete height, and top values change as height changes. How do we keep LayoutTree stable? 🤔
That's a good question, and the handling is fairly direct: for blocks without a height, just give them a default fallback height. That way every block has a height, and we can calculate each block's top value in order. It's just a matter of continuously adding up the block heights.
Clever you might ask again: what if a block without a height later renders and gets a new height that doesn't match the fallback? 🤔 In that case, we just dynamically correct that block's height and the top values of all blocks after it. That way the layout information on LayoutTree is correct.
But won't that cause the page to jump suddenly 😦? For example, the current viewport shows block10, and because block3's height changed, block10 gets pushed down, so the page keeps jumping? This is another good question 👍🏻. So we need an extra feature that locks the current viewport block. What does that mean? Besides dynamically updating height and top as mentioned, we also need to dynamically compensate the scroll distance scrollTop, so that the value of scrollTop keeps block10 in the viewport, reducing the feeling of jumping as much as possible. Roughly speaking, if block3's height increases by 100, then the corresponding scrollTop should also increase by 100, and block10's top should also increase by 100, like this:
Block3 height: +100 // moves down
Block10 top: +100
scrollTop: +100 // scrolls up
Block10's relative position on screen: unchanged
So a long document doesn't need to create all the DOM first to know the total height. Instead:
Estimate the whole document with default heights
→ render the first screen
→ correct the first screen's heights
→ progressively correct later nodes while scrolling
To make it easier to understand, here's a simple LayoutTree data structure:
const layoutTree = { // left can default to 0; width can default to full width
blockId1: { top: 0, left: 0, height: 40, width: 100, ... },
blockId2: { top: 40, left: 0, height: 120, width: 200, ... },
blockId3: { top: 160, height: 40, ... },
};
There's another problem here: how do we know a block's height changed, and when should we update the height? Use MutationObserver? Theoretically possible, but usually not done, because MutationObserver can contain a lot of data and useless information. The best way is for the block itself to send a notification to LayoutTree saying "my height may change", and let LayoutTree update. For example, after operations like image loading complete, text content changes, font loading and application complete, etc., trigger that block's update. This gives fairly precise updates.
How do you do local rendering? Mature implementations usually don't directly run map(render) over all children. Instead, LayoutTree first finds the Viewport and Buffer nodes, calculates which children need to be rendered this round, and renders only that part. The unmounted continuous ranges are merged into empty placeholder elements. This way, logically there is still a complete document tree, but physically only a small part of the DOM exists.
Here's a simple example 🌰 to make it easier to understand:
// current viewport
curBlock = [A, B, C, D, placeholder EF];
// 0. scroll down by two blocks
// 1. build the target sequence
newBlock = [placeholder AB, C, D, E, F, placeholder GH];
// 2. delete invalid real nodes
curBlock = [C, D, placeholder EF];
// 3. tidy up placeholders
curBlock = [placeholder AB, C, D, placeholder GH];
// 4. insert or move Blocks
curBlock = [placeholder AB, C, D, E, F, placeholder GH];
Data Compression and Collaboration
One of the most important kinds of data in online documents is text, and it has rich text styles. Intuitively, we could give a simple data structure like this:
[
{ "str": "Hello ", "bold": true },
{ "str": "World", "link": "https://example.com" }
]
But usually, rich text isn't suitable for saving a complete style object for every character, because many styles can be reused. A more common approach is to separate plain text, attribute application, and an attribute pool, like this:
{
"text": "Hello World", // plain text
"attrs": "*0+6*1+5", // *which attribute +applied to which position
"aPool": { // shared attribute pool
"0": ["bold", true],
"1": ["link", "https://example.com"]
}
}
You can understand *0+6*1+5 as:
*0+6: the next 6 characters use attribute pool 0's bold effect.*1+5: the next 5 characters use attribute pool 1's link effect.
When finally passed to React/Vue, it's organized as:
[
{ "text": "Hello ", "mark": { "bold": true } },
{ "text": "World", "mark": { "link": "https://example.com" } },
]
The final rendered effect becomes:
Hello World
The attribute pool data structure above is what actually gets persisted to the database. How is it used in memory? 🤔 This is where I have to briefly mention collaboration. One of the core features of online documents is collaboration. Besides letting users see changes in real time, the ultimate goal of collaboration is to guarantee eventual consistency of data.
How is it implemented? In the document field, using the otjson algorithm is more appropriate. Explaining it costs a lot, but luckily AI is here now. You don't have to understand it to have AI write it; you just need to know the term. So I'll go through it quickly. The core foundation of this algorithm is that you need to split document operations into atomic operations that cannot be decomposed further, and every other operation can be composed from these atomic operations. For documents, we only need three basic operations:
- insert
- retain
- delete
Looking back at Hello World, when we fetch the document data we need to convert it into these atomic operations:
ops = [
{ insert: "Hello ", attrs: { bold: "true" } },
{ insert: "World", attrs: { link: "https://example.com" } },
]
Then it's converted into the editor's specific in-memory structure. I won't go into detail here; it depends on each editor's own implementation. The essence of editing is, based on an old version, retaining/deleting/inserting which characters, and which attributes the inserted text uses. So a Block also needs a version field:
type Block = {
id: string;
type: 'text' | 'image' | 'table' | '....';
parentId: string | null;
children: string[];
data: Record<string, any>;
version: string; // +1 on every edit operation
}
The editor itself is a very complex independent module. If you need it, ask AI. Here I'll just give a basic structure so you have an impression:
Editor
├── Input Module # input pipeline: events → character deltas → model
├── Selection # cross-block selection
├── Undo Module # undo stack
├── Plugin # plugin registry
├── ViewModel # view state
└── Content
├── Zone A: TextBlock A
├── Zone B: TextBlock B
└── Zone C: Heading C
No matter how many text blocks there are, the whole page should ideally have one Editor instance + one shared Content, rather than each TextBlock building its own complete editor. Why? Because some things are naturally cross-block:
- Dragging with the mouse from the first paragraph to the fifth paragraph selects a range: this is a cross-block selection
- Ctrl+C / Ctrl+V copy and paste can span several blocks
- Ctrl+Z undo can change three blocks in one operation
If each text block manages itself, all three of these require you to implement cross-block communication yourself, and the more you write, the messier it gets. Centralizing into one Editor lets you answer "what is the global state now, what did I change, and how do I roll back".
Here's another image to help understand the basic structure of a TextBlock:
So how do you optimize a very long text? The best way is to split it into multiple TextBlocks; the second best is to split one TextBlock into multiple logical lines.
Edit Flow
After you do some operations on a document, how does the whole document run? This uses a common frontend idea: one-way data flow. Remember: don't let "user operations" directly modify the DOM. The DOM should always be the result. No matter what the user does, the first step should always be to process the data, and the second step is to let the data naturally map to the DOM for display. Here's how an edit operation lands:
- User input: for example, typing a character, or dragging an image.
- Generate an Operation: translate the user action into basic operations, such as insertText, moveBlock, replaceChildren.
- Update the local Block Store: don't change the DOM first; change the model data first.
- Mark dirty zones: determine which block, which parent node, and which subtree are affected.
- Incremental rendering: only let the affected parts recalculate their views and update the page.
How Blocks Extend
A document can't always have only text and images. Tables, charts, code, files, canvases, third-party components, and more will keep being added. If every new Block type requires modifying the core renderer, it will eventually grow into a huge conditional branch. So a more reasonable approach is registration. Take a chart Block as an example:
registerBlock({ // agreed-upon protocol
type: "chart",
model: ChartBlockModel, // chart data
view: ChartBlockView, // chart view, i.e. rendering
layout: ChartLayout, // chart Block container structure info, such as width/height
commons: [Commentable, Resizable] // required common capabilities, such as comments and resize/drag
});
The kernel only cares about the unified protocol:
- What is your type?
- How is the Model created?
- How is the View rendered?
- How is the Layout calculated?
- Which capabilities do you combine?
As for whether the chart internally uses Canvas or SVG, and how it handles it, the kernel doesn't need to know. This is a typical application of microkernel and plugin thinking in editors. If you want to learn about this idea, you can check out my other article: 🔥 Microkernel Architecture in Frontend and Its Applications.
Sync Blocks
Normally, one Record corresponds to one Block, but the architecture allows the same Record data to appear in multiple positions, even shared across documents. This reuses the same data, so a change in one place takes effect everywhere. Let's look at the rough structure of this type:
Page.children = [..., A, ..., A, ...] // repeated Blocks, their display stays in sync
Record A: real data // this A is the recordId
├── Block #101: body position // this 101 is the blockId
└── Block #208: reference position // this 208 is the blockId
You'll notice there are actually two kinds of ids (this is important to understand 😄):
| ID | Meaning |
|---|---|
| recordId | The data entity, stable across sessions, actually stored, unchanged on refresh. Collaboration, saving, and recovery all use it. |
| blockId | The runtime structure instance for this session, only used as a component key in the currently open editor. It isn't actually stored and may change on every refresh. |
A rough way to understand it: Record means "specific data", Block means "where it is". Essentially they form a reference relationship. Since we're mentioning ids, let me also address a common phrase: "find the old instance and reuse it." What exactly is being reused? This is easy to make mysterious. Actually, you can understand it very plainly:
It's not brute-force searching a node across the whole page; it's using a stable key in the same parent's children list to find the old render unit at that position from the previous round.
Whether you're using React, Vue, or a self-built rendering layer underneath, as long as you follow the declarative update path, the idea is similar:
- The parent node produces a new set of children
- Each child carries a stable key
- The framework or renderer aligns old and new children by key
- Reuse what can be reused; create or delete what can't
So the meaning of a stable id isn't just "easy to find"; more importantly, it gives every Block a persistent identity. If identity is stable, state can be stable, components can be reused, and scroll position, selection, and local cache won't jump around.
Separation of Responsibilities
Here's a simple image for understanding (you can skip it):
There are actually two different "trees" in a document:
- One is who is whose child (structure)
- One is who is where and how tall (layout)
Don't mix them into one.
- StructureTree: parent-child relationships. If an image has a caption below it, structurally the caption is a child of the image.
- LayoutTree: rendering layout. It only cares about each block's position and height on the page, so it can calculate "where do I scroll to" and "which blocks should be rendered in the viewport".
demo
If you want to implement a generic Block editor from scratch, you don't need to pursue full document features at the start. You can follow this route, adding layers step by step 👇🏻:
- First support 3 minimal Blocks: heading, paragraph, image
- Change the data to a blockMap + children index structure
- Split runtime state into a separate store
- Let each BlockNode subscribe precisely by blockId
- Then add Operation, History, Selection
- Finally add collaboration, virtual scrolling, and complex containers
The benefit of this approach is that every time you add a layer of capability, the previous design naturally supports it, instead of forcing a rewrite.
Summary
Many people looking at Block architecture first see its shape: a title is a block, an image is a block, a table is also a block. That's only the surface. What's really powerful is this: it splits a continuously growing editor into a set of minimal units that can be independently understood, independently evolved, and independently optimized. You can optimize the image block alone, extend the table block alone, or redo the selection system alone, without touching the whole editor every time.
What Block architecture really solves is not "how to lay out" but "how to evolve". The core of a generic Block architecture is not "there are many blocks", but "every block has a stable identity, a clear boundary, and an independent lifecycle". When content, structure, state, and rendering are truly separated into layers, an editor moves from "usable" to "maintainable in the long term" 🚗.
But now that AI is here, you may not need to know exactly how otjson and the Editor are implemented; just a general idea is enough.
But we often face a problem: not knowing what a technology is called. That's also a big obstacle to progress 😂. I often wonder: how do people who are good at optimizing and giving suggestions know these optimization methods? Is it because they've seen similar ideas before? If I had never worked with documents, where would I have learned this knowledge? Fortunately, now there's AI. When you don't know what can be optimized, throw the problem at it and directly ask AI what optimization space there is. That feels great. Including questions you're embarrassed to ask, or questions that might seem like basic knowledge to others, you can ask freely 🎉. Alright, I'll stop writing here. Time to go into hiding for another half year 👋🏻...
Soft ad: Does anyone in Shenzhen need a badminton practice partner? You can find me hahaha 😂
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
What's your Zhongyu level? And you want to be a sparring partner? [看]
Hahaha, I'm dying — it's you again. Level 4, come play sometime [大笑]