Building a Browser-Based Code Editor

A comprehensive guide to building a performant, feature-rich code editor that runs entirely in the browser, covering architecture decisions, rendering strategies, and the trade-offs involved.

technical9 min readBy Klivvr Engineering
Share:

The idea of writing code inside the very platform that runs it has a certain elegance. Browser-based code editors have evolved from simple textarea elements with syntax highlighting bolted on top into sophisticated development environments that rival desktop applications. At Klivvr, building Kodepad meant confronting every challenge this domain presents: performant text rendering, language intelligence, real-time feedback, and the constraints of a sandboxed runtime. This article distills what we learned about building a browser-based code editor from the ground up, covering the architectural decisions that matter most and the trade-offs that are easy to overlook.

Whether you are building your own editor or evaluating existing solutions to embed in a product, understanding these fundamentals will help you make informed choices and avoid common pitfalls.

The Document Model: Beyond Strings

The most fundamental decision in any code editor is how to represent the document in memory. A naive approach stores the entire file as a single string and performs insertions and deletions with substring operations. This works for documents under a few hundred lines but degrades rapidly as file size increases. Every keystroke triggers an O(n) copy of the entire buffer, and line-based operations like "go to line 200" require scanning the whole string for newline characters.

Production editors use specialized data structures to solve this problem. The three most common are gap buffers, piece tables, and rope trees. Each has distinct performance characteristics:

// A simplified piece table implementation
interface Piece {
  source: "original" | "added";
  offset: number;
  length: number;
}
 
class PieceTable {
  private original: string;
  private added: string;
  private pieces: Piece[];
 
  constructor(initialText: string) {
    this.original = initialText;
    this.added = "";
    this.pieces = [
      { source: "original", offset: 0, length: initialText.length },
    ];
  }
 
  insert(position: number, text: string): void {
    const addedOffset = this.added.length;
    this.added += text;
 
    const { pieceIndex, offsetInPiece } = this.findPiece(position);
    const piece = this.pieces[pieceIndex];
 
    // Split the existing piece and insert the new one
    const before: Piece = {
      source: piece.source,
      offset: piece.offset,
      length: offsetInPiece,
    };
    const inserted: Piece = {
      source: "added",
      offset: addedOffset,
      length: text.length,
    };
    const after: Piece = {
      source: piece.source,
      offset: piece.offset + offsetInPiece,
      length: piece.length - offsetInPiece,
    };
 
    this.pieces.splice(pieceIndex, 1, before, inserted, after);
  }
 
  private findPiece(position: number): {
    pieceIndex: number;
    offsetInPiece: number;
  } {
    let remaining = position;
    for (let i = 0; i < this.pieces.length; i++) {
      if (remaining <= this.pieces[i].length) {
        return { pieceIndex: i, offsetInPiece: remaining };
      }
      remaining -= this.pieces[i].length;
    }
    return {
      pieceIndex: this.pieces.length - 1,
      offsetInPiece: this.pieces[this.pieces.length - 1].length,
    };
  }
 
  getText(): string {
    return this.pieces
      .map((p) => {
        const buffer = p.source === "original" ? this.original : this.added;
        return buffer.substring(p.offset, p.offset + p.length);
      })
      .join("");
  }
}

The piece table approach, famously used by VS Code's Monaco Editor, provides efficient inserts and deletes while maintaining an append-only added buffer that simplifies undo history. For Kodepad, we leverage Monaco's built-in piece table rather than implementing our own, but understanding the underlying model informs decisions about features that interact with the document, such as collaborative editing and diff computation.

Rendering: Virtual Viewports and Layered Painting

Rendering a code editor is not the same as rendering a web page. Standard DOM-based text rendering introduces layout thrashing, reflow costs, and limited control over character-level positioning. Modern browser editors use one of two rendering strategies: DOM-based virtual viewports or canvas-based rendering.

DOM-based virtual viewports render only the lines currently visible in the scroll area, plus a small overscan buffer above and below. As the user scrolls, offscreen lines are destroyed and new lines are created. This keeps the DOM node count constant regardless of file size:

interface ViewportState {
  firstVisibleLine: number;
  lastVisibleLine: number;
  overscan: number;
  lineHeight: number;
  scrollTop: number;
}
 
function computeViewport(
  totalLines: number,
  containerHeight: number,
  scrollTop: number,
  lineHeight: number,
  overscan: number = 5
): ViewportState {
  const firstVisibleLine = Math.max(
    0,
    Math.floor(scrollTop / lineHeight) - overscan
  );
  const visibleCount = Math.ceil(containerHeight / lineHeight) + 2 * overscan;
  const lastVisibleLine = Math.min(
    totalLines - 1,
    firstVisibleLine + visibleCount
  );
 
  return {
    firstVisibleLine,
    lastVisibleLine,
    overscan,
    lineHeight,
    scrollTop,
  };
}
 
function renderLines(
  viewport: ViewportState,
  getLineContent: (line: number) => string
): HTMLElement[] {
  const elements: HTMLElement[] = [];
  for (let i = viewport.firstVisibleLine; i <= viewport.lastVisibleLine; i++) {
    const lineEl = document.createElement("div");
    lineEl.className = "editor-line";
    lineEl.style.position = "absolute";
    lineEl.style.top = `${i * viewport.lineHeight}px`;
    lineEl.style.height = `${viewport.lineHeight}px`;
    lineEl.textContent = getLineContent(i);
    elements.push(lineEl);
  }
  return elements;
}

Canvas rendering offers even finer control and avoids DOM overhead entirely, but it sacrifices accessibility and requires reimplementing text selection, cursor blinking, and input handling from scratch. Monaco uses the DOM approach with absolute positioning, which gives a good balance of performance and browser integration. Kodepad inherits this strategy and supplements it with custom overlays for features like inline error annotations and collaborative cursors.

Syntax Highlighting: TextMate Grammars and Tree-sitter

Syntax highlighting is the feature users notice immediately and the one most likely to cause performance issues if implemented poorly. The two dominant approaches in the browser ecosystem are TextMate-style grammar tokenization and Tree-sitter-based incremental parsing.

TextMate grammars define tokenization rules as a stack of regular expressions organized into scopes. Monaco ships with a lightweight TextMate tokenizer called Monarch that processes text line by line. This is adequate for most languages and has the advantage of being configurable without compiling native code:

// Monarch language definition for a simplified TypeScript subset
const typescriptLanguage = {
  tokenizer: {
    root: [
      [/[a-zA-Z_]\w*/, {
        cases: {
          "@keywords": "keyword",
          "@typeKeywords": "type",
          "@default": "identifier",
        },
      }],
      [/"([^"\\]|\\.)*$/, "string.invalid"],
      [/"/, "string", "@string"],
      [/\/\/.*$/, "comment"],
      [/\/\*/, "comment", "@comment"],
      [/\d+/, "number"],
      [/[{}()\[\]]/, "@brackets"],
    ],
    string: [
      [/[^\\"]+/, "string"],
      [/\\./, "string.escape"],
      [/"/, "string", "@pop"],
    ],
    comment: [
      [/[^/*]+/, "comment"],
      [/\*\//, "comment", "@pop"],
      [/[/*]/, "comment"],
    ],
  },
  keywords: [
    "const", "let", "var", "function", "return", "if", "else",
    "for", "while", "class", "interface", "type", "import", "export",
  ],
  typeKeywords: ["string", "number", "boolean", "void", "any", "never"],
};

Tree-sitter, originally built for Atom and now used in Neovim, Zed, and other editors, takes a fundamentally different approach. It builds a full concrete syntax tree and updates it incrementally on each edit. The result is faster re-highlighting after edits and more accurate structural understanding. The trade-off is that Tree-sitter requires compiling language grammars to WebAssembly for browser use, adding build complexity and increasing bundle size.

For Kodepad, we use Monaco's built-in Monarch tokenizer for HTML, CSS, and TypeScript because the languages are well-supported out of the box and the performance characteristics are acceptable for the file sizes typical in a playground environment.

Input Handling and Accessibility

Handling keyboard input in a code editor is surprisingly complex. You cannot simply listen for keydown events and insert characters. Composition events from input method editors, screen reader announcements, clipboard operations, and drag-and-drop all require careful handling.

The standard pattern is to use a hidden textarea that receives all input. The editor positions this textarea behind the visible cursor and processes its content on each input event. This approach works with IME composition, browser autofill, and assistive technology:

class EditorInput {
  private textarea: HTMLTextAreaElement;
  private onInput: (text: string) => void;
 
  constructor(container: HTMLElement, onInput: (text: string) => void) {
    this.onInput = onInput;
    this.textarea = document.createElement("textarea");
    this.textarea.className = "editor-hidden-input";
    this.textarea.setAttribute("aria-label", "Code editor input");
    this.textarea.setAttribute("role", "textbox");
    this.textarea.setAttribute("aria-multiline", "true");
    this.textarea.style.cssText = `
      position: absolute;
      opacity: 0;
      width: 1px;
      height: 1px;
      overflow: hidden;
    `;
    container.appendChild(this.textarea);
 
    this.textarea.addEventListener("input", this.handleInput.bind(this));
    this.textarea.addEventListener(
      "compositionend",
      this.handleCompositionEnd.bind(this)
    );
  }
 
  private handleInput(event: Event): void {
    const inputEvent = event as InputEvent;
    if (inputEvent.isComposing) return; // Wait for compositionend
    const text = this.textarea.value;
    if (text) {
      this.onInput(text);
      this.textarea.value = "";
    }
  }
 
  private handleCompositionEnd(): void {
    const text = this.textarea.value;
    if (text) {
      this.onInput(text);
      this.textarea.value = "";
    }
  }
 
  focus(): void {
    this.textarea.focus();
  }
}

Accessibility is not optional. Kodepad uses ARIA live regions to announce cursor position changes, line content under the cursor, and diagnostic messages to screen readers. The hidden textarea approach naturally integrates with VoiceOver, NVDA, and JAWS because the browser treats it as a standard text input.

Performance Budgets and Profiling

A code editor must respond to every keystroke within 16 milliseconds to maintain 60fps. This budget includes tokenization, document updates, viewport computation, and DOM mutations. Exceeding this budget results in visible lag that erodes user trust.

We establish performance budgets for each stage of the editing pipeline and enforce them with automated benchmarks:

interface PerformanceBudget {
  documentUpdate: number;   // Max ms for piece table insert
  tokenization: number;     // Max ms for re-tokenizing affected lines
  viewportCompute: number;  // Max ms for viewport recalculation
  domMutation: number;      // Max ms for DOM updates
  total: number;            // Max ms total keystroke-to-paint
}
 
const BUDGET: PerformanceBudget = {
  documentUpdate: 1,
  tokenization: 3,
  viewportCompute: 1,
  domMutation: 8,
  total: 16,
};
 
function profileKeystroke(
  action: () => void,
  budget: PerformanceBudget
): void {
  const start = performance.now();
  action();
  const elapsed = performance.now() - start;
 
  if (elapsed > budget.total) {
    console.warn(
      `Keystroke exceeded budget: ${elapsed.toFixed(2)}ms > ${budget.total}ms`
    );
  }
 
  // In development, use Performance Observer for detailed breakdowns
  if (import.meta.env.DEV) {
    performance.mark("keystroke-end");
    performance.measure("keystroke", "keystroke-start", "keystroke-end");
  }
}

In practice, the tokenization phase is the most variable. A single-character edit on a long line with complex regex-based syntax rules can spike tokenization time. We mitigate this by deferring tokenization of off-screen lines and using requestIdleCallback for non-urgent re-tokenization passes.

Conclusion

Building a browser-based code editor is an exercise in managing complexity across multiple interacting systems: document representation, rendering, language intelligence, input handling, and performance optimization. Each layer has well-established solutions, but integrating them into a coherent product requires careful attention to the boundaries between them.

For Kodepad, the pragmatic choice was to build on Monaco Editor's foundation rather than starting from scratch. Monaco provides a battle-tested document model, rendering pipeline, and input system. Our effort focuses on the layers above: the sandboxed execution environment, the live preview system, and the collaborative features that make Kodepad more than just an editor in a browser. Understanding the foundational layers described in this article ensures that we make informed decisions when extending or customizing the editor's behavior, and it helps diagnose issues when they inevitably arise at the intersection of browser APIs and editor internals.

Related Articles

business

Rapid Prototyping: From Idea to Demo in Minutes

How browser-based code playgrounds enable rapid prototyping workflows that compress the journey from concept to working demo, and why this capability is a competitive advantage for engineering teams.

8 min read
business

Code Playgrounds in Developer Education

How code playgrounds are transforming developer education by providing immediate feedback, reducing setup barriers, and enabling interactive learning experiences that scale.

8 min read
business

Developer Tools That Boost Productivity

An exploration of how browser-based developer tools like code playgrounds reduce friction in the development workflow, accelerating everything from prototyping to debugging to knowledge sharing.

7 min read