Guide · Frontend
Browser Code Editor Design: Piece Tables, Incremental Parsing and LSP
28 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Browser code editor design splits the job into parts that each do little work per keystroke. A data structure holds the text, a parser works out its structure, a view draws it, and language tools answer questions.
- A textarea, the browser's built-in plain-text box, cannot colour a single word. In our test in Chromium, the open-source core of Chrome, one insert into a 100,000-line textarea took 1.2 seconds.
- Real editors store text as a balanced tree of text chunks or as a piece table. A piece table is a list of pointers into the original file and into a buffer of typed text that only grows. One edit then touches a few small objects.
- Incremental parsing keeps the old syntax tree, the parser's map of how the code nests, and rebuilds only the part an edit touched. Lezer, the parser behind the open-source CodeMirror editor, re-parsed a 2 MB file in about 6 ms instead of about 780 ms.
- Beginners can stop after the incremental parsing section. Engineers then get CodeMirror's undocumented parse budget, the time it allows itself for parsing, and the large-file cut-offs of Monaco, the editor inside VS Code. They also get background threads called web workers and a hand-written session with a real language server, a program that supplies completions and errors.
What goes wrong when you build a code editor on a textarea?
It works until the file gets big or the user wants colours. Browser code editor design starts from that failure. A textarea holds only plain text, so it cannot colour keywords, underline errors or show completions.
Picture a common setup. A coding-practice site in Bengaluru gives students an in-browser editor for their solutions, plus a read-only view of a 2 MB generated JavaScript file of about 1 lakh (100,000) lines. The setup is made up, but every measured timing below comes from our own runs.
The first version used a textarea for typing and a separate layer of coloured spans, small inline page elements, on top, rebuilt on every key press. In our Chromium run, just building and laying out spans for 100,000 lines took 2.3 seconds.
Real editors remove that work layer by layer. This guide follows one keystroke through each layer and shows, with runs on pinned versions, what each layer saves. For stress tests we also used a made-up file of 10 lakh (1,000,000) lines and 51.7 million characters.
Which words do you need before reading about code editors?
You need eight words. Each one maps to a part of the practice site's editor.
- Document model: the data structure that holds the text in memory, separate from what is on screen.
- DOM: the Document Object Model, the browser's tree of page elements. Every element costs memory and layout time.
- Token: a small labelled piece of code, such as a keyword, a string or a number. Tokenising means cutting text into tokens.
- Syntax tree: a tree that shows how tokens nest, such as a statement inside a function. A parser is the program that builds it.
- Viewport: the part of the document visible on screen at the moment.
- Main thread: the one browser thread that runs page JavaScript, handles input and draws the page. Long work there freezes typing.
- Web worker: a background thread for JavaScript. It cannot touch the DOM and talks to the page by sending messages.
- Language server: a separate program that knows one language deeply and answers questions such as which words can come next.
How does browser code editor design work?
A browser code editor splits the work into layers, and each layer touches only what changed or what is visible. That keeps the cost of one keystroke small, whatever the file size.
When a student types, the editor first captures the key. CodeMirror 6, an open-source browser editor, uses an editable element it manages itself. A mutation observer, a browser feature that reports DOM changes, turns those changes into document changes.
Monaco, the editor inside Microsoft's VS Code, captures keys with a textarea it keeps invisible, with transparent text placed behind the editor. Where the browser supports it, it uses EditContext instead, a newer API for receiving typed text while the page draws the text itself.
The change becomes a transaction, a small record of what changed: where the change starts and ends, and the new text. The document model applies it, the parser updates its tree, and the view redraws only the lines on screen.
Language features, such as completion and error checking, are slower. Well-built editors run the heavy ones in a web worker or a separate language server, so typing does not wait for them.
One keystroke through a browser code editor
Drag to exploreNotice that the change, not the whole file, travels between layers. Only visible lines are drawn, and the slow language work sits off the main thread.
Diagram connections:
- Key press or paste to Input capture (DOM events)
- Input capture to Transaction (becomes)
- Transaction to Document model (applied to)
- Document model to Incremental parser (changed range)
- Incremental parser to Viewport renderer (tokens for visible lines)
- Document model to Viewport renderer (visible text)
- Transaction to Worker or language server (change sent as a message)
- Worker or language server to Viewport renderer (squiggles, completions)
- Key press or paste: From the keyboard, clipboard or drag
- Input capture: Editable element or hidden textarea
- Transaction: Where the change starts, ends and new text
- Document model: Tree of line chunks or piece tree
- Incremental parser: Rebuilds only the edited part of the tree
- Viewport renderer: Draws visible lines plus a margin
- Worker or language server: Completion and errors, off the main thread
Why is a textarea not enough for a code editor?
Because a textarea is a plain-text control. MDN lists its permitted content as text only, so there is no way to put a coloured span, an error underline or an inline hint inside it.
Size is the second problem. In headless Chromium 153, which runs without a visible window, on a 2-core container, setting a 100,000-line value took 1.0 second. Inserting one character and waiting for layout took 1.17 seconds.
The tempting fix is the naive design: draw every line yourself as a div with coloured spans. It is shown here because it is wrong for big files. For 100,000 lines with three spans each, we got 400,000 elements and 2.3 to 2.8 seconds to build and lay out.
Real editors keep the textarea idea only for input, if at all. They draw text themselves, and they draw only the part you can see.
How do code editors store text: piece tables, ropes and trees?
They store text so that one edit changes a few small objects instead of copying or re-splitting the whole file. The three common shapes are an array of lines, a piece table and a balanced tree of chunks.
The naive array of lines
The simplest model keeps one string per line in an array. Finding line N is instant, which is why it is tempting. VS Code used it until version 1.21, according to a March 2018 post on the VS Code blog.
The same post reports the cost. One user could not open a 35 MB file, because its 13.7 million lines made the line array use around 600 MB.
Piece tables: never move the original text
A piece table keeps two buffers. The original buffer holds the file as loaded and never changes. The add buffer holds everything typed, and only grows at its end.
The document is a list of pieces. Each piece says which buffer, where to start and how many characters to take. An insert splits one piece and adds a new one; no old text moves.
class PieceTable {
constructor(text) {
this.original = text; // the file as loaded: never changed
this.add = ""; // everything typed, append only
this.pieces = [{ buf: "original", start: 0, length: text.length }];
}
locate(pos) { // which piece holds offset pos, and where inside it
let at = 0;
for (let i = 0; i !== this.pieces.length; i++) {
const end = at + this.pieces[i].length;
if (Math.min(pos, end) === pos) return { i, inner: pos - at }; // pos is not past this piece
at = end;
}
}
insert(pos, text) {
const { i, inner } = this.locate(pos);
const p = this.pieces[i];
if (p.buf === "add" && inner === p.length && p.start + p.length === this.add.length) {
this.add += text; // typing straight on: grow the newest piece
p.length += text.length;
return;
}
const piece = { buf: "add", start: this.add.length, length: text.length };
this.add += text;
const left = { ...p, length: inner };
const right = { ...p, start: p.start + inner, length: p.length - inner };
this.pieces.splice(i, 1, ...[left, piece, right].filter(function (x) { return x.length !== 0; }));
}
text() {
const self = this;
return this.pieces.map(function (p) { return self[p.buf].slice(p.start, p.start + p.length); }).join("");
}
}
const doc = new PieceTable("let x = 1;\nlet z = 3;\n");
doc.insert(11, "let y = 2;\n");
doc.insert(22, "// done\n");
console.log(JSON.stringify(doc.text()));
console.log(doc.pieces.map(function (p) { return `${p.buf}[${p.start}, +${p.length}]`; }).join(" "));Running it with Node 22 prints the four-line text, then original[0, +11] add[0, +19] original[11, +11]. The second insert landed right after the first, so the newest piece just grew.
We checked a longer version with deletes against a plain string over 5,000 random edits, and every result matched. Typing 1,000 characters in a row into a 51.7 million character file took 0.1 ms and left 3 pieces.
Scattered edits are the weak spot. After 10,000 inserts at random places we had 19,999 pieces, and our locate method walks the list from the start each time.
VS Code's fix is the piece tree: a piece table whose pieces sit in a red-black tree, a self-balancing search tree. Each node caches its line breaks, so finding an offset or a line number takes logarithmic time: doubling the file adds about one step.
The VS Code blog says memory right after loading was very close to the file size. The monaco-editor 0.57.0 package still ships this buffer, in a folder named pieceTreeTextBuffer.
CodeMirror's tree of line chunks
CodeMirror's Text type stores the document split by line in a tree. In @codemirror/state 6.7.6, each leaf holds an array of up to 32 line strings, and branch nodes group leaves into a balanced tree.
An everyday picture helps. Think of a large library: the building is the root, each floor is a branch and each shelf is a leaf holding up to 32 books, the lines.
Every floor keeps a count of its books (lines) and letters (characters). To find book 500,000, you read the floor counts and walk straight to one shelf instead of checking every shelf.
Where the picture breaks: a librarian edits a shelf in place, but CodeMirror never changes a node. It builds a new shelf and new floor records, and the old library stays intact for undo and comparison.
import { Text } from "@codemirror/state";
const lines = Array.from({ length: 1_000_000 }, function (_, i) {
return `const value${i} = compute(${i}); // line ${i + 1}`;
});
const doc = Text.of(lines);
const line = doc.line(500_000);
const edited = doc.replace(line.from, line.from, Text.of(["x"]));
console.log(doc.lines, doc.length);
console.log(edited.line(500_000).text.slice(0, 12));
console.log(doc.line(500_000).text.slice(0, 12));With @codemirror/state 6.7.6 on Node 22 this prints 1000000 51666675, then xconst value, then const value4. The new document has the x, and the old document is unchanged.
Pause and predict: the million-line tree holds 32,340 objects. After inserting one character on line 500,000, how many of them are new? Make a guess before reading on.
Only 4. Our test counted 32,336 objects shared with the old tree: the new root, two branches and one leaf were the only new ones. The edit took about 0.6 ms.
Line lookup stays fast too. Finding line 500,000 by scanning a flat string for newlines took 9.9 to 17.6 ms in our runs, while the tree answered in 0.02 to 0.05 ms.
| Model | One edit in the middle | Find line N | Main weakness | Used by |
|---|---|---|---|---|
| Array of lines | Cheap for one line, but every line is its own object | Instant | Memory: around 600 MB for a 35 MB file in VS Code's report | VS Code before 1.21 |
| Piece table (list) | Split one piece; 0.1 ms for 1,000 typed characters | Needs line-break caches | A long piece list after scattered edits | Our test file |
| Piece tree | Split a piece and rebalance a red-black tree | Logarithmic, using cached line breaks | More complex code | VS Code since 1.21, Monaco 0.57.0 |
| Tree of line chunks | 4 new objects, 0.6 ms in our test | 0.02 to 0.05 ms in our test | Leaves still copy their small line arrays | CodeMirror 6 |
A rope, the classic alternative, is a balanced tree whose leaves are string chunks. CodeMirror's Text works like a rope cut at line boundaries, which makes line numbers cheap to count.
How does virtualised rendering keep the DOM small in a code editor?
The editor creates DOM elements only for the lines in view, plus a margin, and uses empty spacer blocks for the rest. CodeMirror's reference manual says it draws only the visible code plus a margin around it.
We loaded the 1,000,000-line file into CodeMirror 6.43.15 in a 600-pixel-tall editor. It created 61 line elements at the top, and 89 after jumping to line 500,000. The DOM never held more than about 90 lines, whatever part of the file was on screen.
The scroll bar still has to feel right. CodeMirror keeps a height map: measured heights for drawn lines and estimates for the rest, based on the line count, the line height and, with wrapping on, the text length.
Huge heights need one more trick. CodeMirror's view source caps its scrollable content at 7,000,000 pixels and scales the regions outside the viewport to fit. Our million lines at 14 pixels each needed 14,000,000 pixels, and the scroller measured 7,000,159.
That cap and the 1,000-pixel margin are read from the 6.43.15 source and are undocumented as of 28 October 2026; pin your version. SevyDevy's virtualised list guide covers the same windowing idea for plain lists.
How does incremental parsing avoid re-highlighting a million lines?
It keeps the syntax tree from before the edit and reuses every piece the edit did not touch. Only the region around the change is parsed again, so the cost follows the size of the edit, not the file.
Many editors tokenise line by line instead of building a full tree. VS Code's 2017 blog says its tokeniser runs line by line, from top to bottom, and stores the state at the end of each line.
After an edit it re-tokenises from the changed line and stops at the first line whose end state matches the stored one. An edit that leaves the end state alone costs one line; opening a block comment changes every later end state.
Parsers such as Lezer and tree-sitter, a C parsing library, build a full syntax tree and still stay incremental. The Lezer guide says the parser takes a cache of tree fragments, pieces of the old tree tagged with the text they cover, and reuses their nodes.
Incremental parsing after a one-character edit
Drag to exploreNotice that the old tree is never edited. It is cut into fragments around the change, and the parser copies whole fragments into the new tree instead of reading their text again.
Diagram connections:
- Old syntax tree to Fragments (TreeFragment.addTree)
- Fragments to Cut at the edit (TreeFragment.applyChanges)
- Cut at the edit to Parser (fragments passed in)
- Parser to Reused nodes (asks for nodes)
- Reused nodes to New syntax tree (fills most of)
- Parser to New syntax tree (new nodes near edit)
- Old syntax tree: Full parse of the file before the edit
- Fragments: addTree marks the old tree as reusable
- Cut at the edit: applyChanges removes the edited range
- Parser: Reparses text near the change only
- Reused nodes: Whole subtrees copied by reference
- New syntax tree: Equal to a fresh parse of the new text
Here is the test, cut down to its core. It builds a 100,000-line file of small functions, changes one plus sign to a minus and re-parses with the old tree's fragments.
import { parser } from "@lezer/javascript";
import { TreeFragment } from "@lezer/common";
const parts = Array.from({ length: 20000 }, function (_, i) {
return `function f${i}(a, b) {\n const total = a + b * ${i};\n if (total === 10) return "ten";\n return total;\n}`;
});
const oldText = parts.join("\n");
const oldTree = parser.parse(oldText);
// Change one character: "+" becomes "-" inside function f10000.
const at = oldText.indexOf("a + b * 10000;") + 2;
const newText = oldText.slice(0, at) + "-" + oldText.slice(at + 1);
const fragments = TreeFragment.applyChanges(
TreeFragment.addTree(oldTree),
[{ fromA: at, toA: at + 1, fromB: at, toB: at + 1 }]
);
let t0 = performance.now();
const newTree = parser.parse(newText, fragments);
console.log("incremental parse ms:", (performance.now() - t0).toFixed(1));
t0 = performance.now();
const fresh = parser.parse(newText);
console.log("full parse ms:", (performance.now() - t0).toFixed(1));
console.log("same tree:", newTree.toString() === fresh.toString());On a cold run, with no warm-up, it printed about 30 to 70 ms for the incremental parse. The full parse took 0.8 to 1.1 seconds, and the last line said same tree: true.
What just happened? Our fuller test warmed the parser first and repeated each parse. The median full parse took 760 to 790 ms, and the median incremental parse about 6 ms, with single runs from 3 to 15 ms.
It also counted reuse. Of the 2,117,779 characters, 99.59% sat inside pieces copied from the old tree, and only 8,670 characters were rebuilt. 3,556 of the 3,629 tree objects were reused as they were.
Now the worst case. Typing an opening block comment marker at the start of function 10,000 turned the rest of the file into one comment. The incremental parse then took 204 to 272 ms, because half the file really did change meaning.
How to check it worked
Install pinned versions
In an empty folder, run npm install @lezer/[email protected] @lezer/[email protected]. In our fresh folder it added 4 packages.
Save and run the script
Save the code above as lezer_incremental.mjs and run node lezer_incremental.mjs. The .mjs ending tells Node the file uses import lines. We used Node 22.
Read the three lines
Expect an incremental time in single or double digit milliseconds and a full parse near one second. Exact numbers depend on your machine.
Check correctness
The last line must print same tree: true. If it prints false, the change ranges passed to applyChanges do not match the edit.
Tree-sitter, which also has a WebAssembly build for browsers, works the same way. Its docs say you first edit the old tree with byte offsets and row-column points, then parse again with the old tree so the new one shares structure.
Why does CodeMirror stop highlighting in the middle of a huge file?
Because it limits how much parsing it does, on purpose. CodeMirror's million-line demo page says highlighting stops at some point if you scroll far enough, and that an inactive editor stops parsing entirely.
The limits live in @codemirror/language 6.13.1. Each transaction may parse for up to 20 ms. If the old parse was unfinished, it stops at the old tree's end or the viewport's end, whichever is later. The rest runs in idle-time slices of at most 100 ms.
Background parsing stops 100,000 characters past the viewport. It gets a budget of 3,000 ms per 30-second window, and later windows start only while the editor has focus. Each change or cursor move while focused adds 50 ms.
Lezer parses from the start of the document. A block comment opened on line 1 changes what line 500,000 means, so the tree grows from the top.
We checked this in headless Chromium with the million-line file. Six seconds after loading, the tree covered 102,411 characters: the 2,410-character viewport plus exactly 100,000.
Then we jumped to line 500,000, about 25.7 million characters in. Visible lines had no highlighting at all. With no input, the parser stopped after 4.8 to 7.7 million characters and stayed there for over half a minute.
When we moved the cursor every 3 seconds, parsing resumed in bursts, a big one each new window and small ones per move. After about two minutes it had reached 20.3 million characters, and line 500,000 was still plain text.
A second run on the same kind of container reached line 500,000 at about 111 seconds. The budget is time, not characters, so a faster machine parses further in each window.
If your app must have a full tree, the language package exports ensureSyntaxTree, which parses up to a position for a timeout you choose. Its type notes also say syntaxTree may return a possibly incomplete tree.
What Monaco does with huge files
Monaco makes a sharper choice. In the monaco-editor 0.57.0 source, largeFileOptimizations is on by default, and a model over 20 MB (Monaco counts about 21 million characters) or 300,000 lines is never tokenised at all.
A source comment says the decision is made when the model is created and then permanently respected. Models over 50 MB are also not copied to the editor's web worker, so most features that run in that worker, such as link detection, skip them. Diff computation is an exception: it forces the sync.
Single lines are capped too. Monarch, Monaco's built-in tokeniser, skips any line of 20,000 characters or more. That is the default of the editor.maxTokenizationLineLength setting, described as being for performance reasons.
| Editor and version | Limit | Value | What the user sees |
|---|---|---|---|
| CodeMirror, @codemirror/language 6.13.1 | Parse time per transaction | 20 ms, up to the viewport end | Typing stays smooth; the tree may lag |
| CodeMirror, @codemirror/language 6.13.1 | Parse ahead of the viewport | 100,000 characters | Far-away code stays unparsed until you scroll |
| CodeMirror, @codemirror/language 6.13.1 | Background budget | 3,000 ms per 30 s, only while focused | Jumping deep into a huge file shows plain text for a while |
| Monaco 0.57.0 | Large file cut-off | Over 20 MB or 300,000 lines | No syntax colours for that file |
| Monaco 0.57.0 | Worker sync cut-off | Over 50 MB | Most worker-based features skip the file |
| Monaco 0.57.0 | Long line cut-off | 20,000 characters per line | That line stays uncoloured |
Why do code editors run language features in web workers?
Because type checking, linting (checking for likely mistakes) and full parses of big files are slow, and on the main thread that time freezes typing. A worker runs the same code in parallel.
The Monaco 0.57.0 README says its language services create web workers to compute heavy work outside the UI thread. The package ships separate workers for TypeScript, CSS, HTML and JSON.
We measured the difference by parsing the 2 MB practice file on the main thread and then in a worker. A 1 ms timer recorded the longest gap the page went without running.
On the main thread, the page froze for 776 to 977 ms across six runs on a 2-core container. With the worker, the longest gap was 6 to 105 ms, while the worker spent 0.9 to 1.1 seconds parsing.
Workers have costs. MDN notes that a worker cannot touch the DOM and that messages are copied, not shared. So a worker should send back small results, such as the tokens for visible lines or a list of errors, rather than a whole tree.
A reply can also arrive after the user has typed more. LSP, covered next, tags each document change with a version number, so the editor can tell which text an answer belongs to.
How does the Language Server Protocol give a browser editor language smarts?
The Language Server Protocol, LSP, is a protocol of JSON messages between an editor and a language server. The editor reports what the user opened and changed, and asks questions such as what can complete at this position.
The official overview gives the reason: without it, each language's features must be rebuilt for every tool. With it, one language server can be reused in many editors, including browser ones.
Every message starts with a Content-Length header giving the content size in bytes, then a blank line, then a JSON-RPC body. JSON-RPC is a small convention for requests with an id, replies and one-way notifications.
We wrote a client by hand and talked to the CSS language server from vscode-langservers-extracted 4.10.0 over standard input and output. This was the completion request it answered:
{"jsonrpc":"2.0","id":2,"method":"textDocument/completion","params":{"textDocument":{"uri":"file:///demo/style.css"},"position":{"line":0,"character":19}}}The server replied with 888 completion items, including color. The pull diagnostics request, textDocument/diagnostic, added in LSP 3.17, reported Unknown property: 'colo' for our typo.
We then sent only the change, one inserted r, because the server had declared incremental sync. The next diagnostics reply was empty.
An LSP session between a browser editor and a language server
Drag to exploreNotice that only two kinds of message carry the document: the full text once on open, then small changes. Every question after that names a position, not the text.
Diagram connections:
- initialize to Capabilities reply (request)
- Capabilities reply to didOpen (then)
- didOpen to completion request (user types)
- completion request to Completion items (reply with same id)
- Completion items to didChange (user keeps typing)
- didChange to Diagnostics (server re-checks)
- initialize: Editor to server: what each side supports
- Capabilities reply: Server to editor: sync kind, providers
- didOpen: Editor to server: full text, version 1
- didChange: Editor to server: one edit, version 2
- completion request: Editor to server: line and character
- Completion items: Server to editor: labels to show
- Diagnostics: Server to editor: errors with ranges
The position trap: UTF-16 by default
LSP positions are a zero-based line and character offset. UTF-16 is the text encoding JavaScript strings use: most characters take one unit, and an emoji takes two. The spec says the encoding defaults to UTF-16 when the server does not pick one, and UTF-16 must always be supported.
Our test line started with a comment holding one emoji. Before the cursor, JavaScript counted 19 UTF-16 units, but the same text was 21 UTF-8 bytes. The server's diagnostic for colo started at character 16, a UTF-16 count.
CodeMirror and Monaco keep text in JavaScript strings, which count UTF-16 units, so the default matches them. LSP 3.17 added positionEncoding negotiation, so a server that counts UTF-8 bytes or whole code points can say so at start-up.
One more detail from the test: this CSS server declares completion support only when the client says it accepts snippets, templates with placeholders. Our first try without that flag still got answers, but the server never advertised them.
For CodeMirror, the @codemirror/lsp-client package, version 6.3.0 as of 28 October 2026, talks to a server through a small Transport object you write. Its README suggests a WebSocket, a long-lived two-way connection, to a remote server, or running the server in the page when it is JavaScript or WebAssembly.
CodeMirror 6 vs Monaco 0.57: which design choices differ?
Both follow the layered design, but they choose differently at almost every layer. CodeMirror is a set of small packages you assemble, and Monaco is VS Code's editor packaged for the web.
| Layer | CodeMirror 6 | Monaco 0.57.0 |
|---|---|---|
| Input | An editable element it manages, watched by a mutation observer | The EditContext API where the browser supports it, otherwise an invisible textarea |
| Document model | Immutable tree of line chunks, up to 32 lines per leaf | Piece tree text buffer from VS Code |
| Highlighting | Lezer incremental parser with a time budget | Monarch, a line-by-line tokeniser that keeps each line's end state |
| Huge files | Parses up to 100,000 characters past the view | No tokenising over 20 MB or 300,000 lines |
| Language smarts | lsp-client package with a transport you provide | Built-in workers for TypeScript, CSS, HTML and JSON |
| Size of setup | Pick only the packages you need | One large package with many languages included |
For the practice site, CodeMirror suits a light editor with a few languages and custom behaviour. Monaco suits an experience close to a full IDE, an integrated development environment, where students expect VS Code's TypeScript help out of the box.
How do you explain browser code editor design in a system design interview?
Start with why a textarea fails, then walk one keystroke through the layers. Give each layer its trick and one number, and finish with what happens at the extremes.
State the requirements
Typing must stay under one frame, about 16 ms, on large files. Highlighting, errors and completion can lag a little behind the text.
Reject the naive design
A textarea cannot hold colour, and redrawing every line as spans means hundreds of thousands of elements for a big file.
Pick a document model
A balanced tree of line chunks or a piece tree, so one edit creates a few nodes and line lookup is logarithmic.
Render only the viewport
Draw visible lines plus a margin, estimate the height of the rest and scale when the total grows too tall.
Parse incrementally
Reuse the old syntax tree outside the edit, run in small time slices, and say what happens when an edit changes everything after it.
Move language work off the main thread
Use web workers or an LSP server, send small changes with version numbers and drop stale replies.
Close with the limits
Name the big-file cut-offs, the long-line cut-off and the UTF-16 position rule, and how you would test each one.
Questions people ask
- Why can't I just use a textarea for a code editor?
- You can for small, plain snippets, but not for a real editor. A textarea holds only plain text, so it cannot colour keywords, underline errors or show hints inside the text. It also slows down badly on big files: in our Chromium test, one insert into a 100,000-line textarea took 1.2 seconds.
- What is a piece table in a text editor?
- A piece table stores the original file in one buffer that never changes and all typed text in an append-only buffer. The document is a list of pieces, each pointing to a stretch of one buffer. An edit splits a piece and adds one, so no existing text moves. VS Code keeps its pieces in a balanced tree, called a piece tree.
- What is incremental parsing?
- Incremental parsing reuses the syntax tree from before an edit and parses again only the text near the change. In our Lezer test on a 2 MB JavaScript file, a one-character edit re-parsed in about 6 ms instead of about 780 ms. Over 99% of the old tree was reused. An edit that changes meaning widely, such as opening a comment, costs more.
- How does CodeMirror handle a file with a million lines?
- It stores the text in a tree of line chunks and draws only about 60 to 90 lines in the DOM. Parsing is limited: it stops 100,000 characters past the visible part and works within a time budget. So the top of the file is highlighted quickly, but jumping deep into it can show plain text for a minute or more on a slow machine.
- Why does Monaco not highlight my large file?
- Because Monaco turns tokenising off for big files. In monaco-editor 0.57.0, with the default largeFileOptimizations setting, a model over 20 MB or 300,000 lines is never tokenised. Lines of 20,000 characters or more are also skipped. With largeFileOptimizations set to false, the size cut-off no longer applies.
- What is the Language Server Protocol used for?
- LSP lets an editor get language features, such as completion, errors and go to definition, from a separate language server. The editor sends the document once and then small changes, and asks questions at positions. One server can then serve many editors. Browser editors connect through a web worker or a WebSocket to a server.
- Should a browser code editor use web workers?
- Yes, for anything slow, such as type checking, linting or full parsing of big files. In our test, parsing a 2 MB file on the main thread froze the page for up to 977 ms. The same parse in a worker left gaps of 105 ms at most. Keep messages small, because data sent to a worker is copied.
Sources and further reading
- CodeMirror System Guide: document as a tree of lines, immutable state, viewport, content DOM
- CodeMirror Reference Manual: Text, EditorView.viewport and visibleRanges
- CodeMirror example: Huge Document (highlighting stops, parser limits work)
- @codemirror/state 6.7.6 source: dist/index.js (Text leaves of 32 lines)
- @codemirror/language 6.13.1 source: dist/index.js (parse budget constants)
- @codemirror/view 6.43.15 source: dist/index.js (viewport margin, 7,000,000 px height cap)
- Lezer System Guide: incremental parsing with tree fragments, error recovery
- Tree-sitter docs: introduction and goals
- Tree-sitter docs: Advanced Parsing, editing a tree and re-parsing
- VS Code blog, 23 March 2018: Text Buffer Reimplementation (piece tree)
- VS Code blog, 8 February 2017: Optimizations in Syntax Highlighting
- monaco-editor 0.57.0 source: esm/vs/editor/common/model/textModel.js (large file thresholds)
- monaco-editor 0.57.0 README (web workers for language services)
- Language Server Protocol: overview
- Language Server Protocol specification 3.18 (base protocol, Position, position encodings)
- vscode-langservers-extracted 4.10.0 source: CSS server capabilities (cssServer.js)
- @codemirror/lsp-client on npm (Transport-based LSP client)
- MDN: Web Workers API
- MDN: the textarea element (permitted content: text)