SevyDevy

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

Notice 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.

javascript
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.

javascript
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.

Three ways to store an editor's text. Timings are from our runs on a 1,000,000-line, 51.7 million character file with Node 22.
ModelOne edit in the middleFind line NMain weaknessUsed by
Array of linesCheap for one line, but every line is its own objectInstantMemory: around 600 MB for a 35 MB file in VS Code's reportVS Code before 1.21
Piece table (list)Split one piece; 0.1 ms for 1,000 typed charactersNeeds line-break cachesA long piece list after scattered editsOur test file
Piece treeSplit a piece and rebalance a red-black treeLogarithmic, using cached line breaksMore complex codeVS Code since 1.21, Monaco 0.57.0
Tree of line chunks4 new objects, 0.6 ms in our test0.02 to 0.05 ms in our testLeaves still copy their small line arraysCodeMirror 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

Notice 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.

javascript
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

  1. Install pinned versions

    In an empty folder, run npm install @lezer/[email protected] @lezer/[email protected]. In our fresh folder it added 4 packages.

  2. 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.

  3. 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.

  4. 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.

Limits that decide when a big file loses highlighting, read from source as of 28 October 2026.
Editor and versionLimitValueWhat the user sees
CodeMirror, @codemirror/language 6.13.1Parse time per transaction20 ms, up to the viewport endTyping stays smooth; the tree may lag
CodeMirror, @codemirror/language 6.13.1Parse ahead of the viewport100,000 charactersFar-away code stays unparsed until you scroll
CodeMirror, @codemirror/language 6.13.1Background budget3,000 ms per 30 s, only while focusedJumping deep into a huge file shows plain text for a while
Monaco 0.57.0Large file cut-offOver 20 MB or 300,000 linesNo syntax colours for that file
Monaco 0.57.0Worker sync cut-offOver 50 MBMost worker-based features skip the file
Monaco 0.57.0Long line cut-off20,000 characters per lineThat 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:

json
{"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

Notice 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.

CodeMirror 6 and Monaco compared on the layers in this guide, from docs and package source as of 28 October 2026.
LayerCodeMirror 6Monaco 0.57.0
InputAn editable element it manages, watched by a mutation observerThe EditContext API where the browser supports it, otherwise an invisible textarea
Document modelImmutable tree of line chunks, up to 32 lines per leafPiece tree text buffer from VS Code
HighlightingLezer incremental parser with a time budgetMonarch, a line-by-line tokeniser that keeps each line's end state
Huge filesParses up to 100,000 characters past the viewNo tokenising over 20 MB or 300,000 lines
Language smartslsp-client package with a transport you provideBuilt-in workers for TypeScript, CSS, HTML and JSON
Size of setupPick only the packages you needOne 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.

  1. 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.

  2. 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.

  3. 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.

  4. Render only the viewport

    Draw visible lines plus a margin, estimate the height of the rest and scale when the total grows too tall.

  5. 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.

  6. Move language work off the main thread

    Use web workers or an LSP server, send small changes with version numbers and drop stale replies.

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

Practise this with Lala

Practise