Why Is Your Terminal App a React App?
A terminal is a grid of characters written as a byte stream. You position a cursor with escape codes. You write text. That's it.
So why are we running a React fiber reconciler, a virtual DOM diffing algorithm, and a C++ flexbox engine compiled to WebAssembly — to print colored text to stdout?
What Ink Actually Does
Ink is a React renderer for terminal applications. When you update state in an Ink component, here is what happens:
- React state change triggers a re-render
- The fiber reconciler diffs the virtual component tree
- Yoga — Meta's C++ flexbox layout engine, shipped as
yoga.wasm— re-runs flexbox constraint solving - Ink serializes the layout result to ANSI escape sequences
- The result is written to stdout
This is the full browser rendering pipeline — style calculation, layout, paint — applied to an 80x24 character grid.
A terminal does not have a DOM. It does not have reflow. It does not have style inheritance cascades or z-ordered layers or async event bubbling. The "optimization" that a virtual DOM provides in the browser context — avoiding expensive reflow on unchanged subtrees — does not translate, because there is no reflow. There is only write().
As one Hacker News commenter put it:
"Terminal rendering is done as a stream of characters. Diffing that is nonsense."
The Numbers
These are real measurements, not hypotheticals.
Memory
| Tool | Architecture | Idle RAM | Peak RAM |
|---|---|---|---|
| Claude Code | Node.js / React (custom renderer) | 360 MB | 746 MB |
| OpenCode | Go / Bubbletea | — | 130 MB |
| Codex | Rust | — | 15 MB |
That is a 24x memory difference between the React terminal app and the Rust terminal app. For tools that do the same thing: send text to an API and print the response.
Claude Code reserves 32.8 GB of virtual memory for the V8 heap. Users have reported the process growing to 93 GB, 120 GB, and 12 GB during normal usage before being OOM-killed. One user hit a heap out-of-memory at 2 GB just processing 720 directory paths — because Node.js loads directory listings entirely into memory with no streaming.
Startup Time
- Claude Code: 3-4 seconds
- A compiled Rust binary: ~50 milliseconds
Cold Start (Lambda Benchmarks, 2024)
| Runtime | Cold Start |
|---|---|
| Rust | 20.86 ms |
| Go | 39.67 ms |
| Node.js | 132.71 ms |
That is a 6x cold start penalty for Node.js versus Rust, before a single line of application code runs.
The Flexbox Engine in Your Terminal
Ink ships Yoga, Meta's cross-platform layout engine written in C++, compiled to WebAssembly. This is the same engine that powers React Native's layout system. It solves flexbox constraints — justify-content, align-items, flex-wrap — so that Ink components can be positioned in the terminal.
You are running a C++ flexbox solver compiled to WASM, called via FFI, to determine where to place text characters in a grid.
This dependency also means you cannot compile an Ink application to a single binary. When using bun build --compile, apps using Ink fail with "Cannot find module ./yoga.wasm". The WASM file uses relative path resolution that breaks in standalone binary contexts.
A Rust TUI application compiles to a single static binary with zero runtime dependencies. It ships one file. It runs everywhere.
The Supply Chain
Ink has 25 direct production dependencies. The transitive dependency count is significantly higher. This is the npm ecosystem, so that number is not surprising. What is surprising is what happened to five of those direct dependencies.
In September 2025, the Shai-Hulud attack compromised multiple npm packages via phishing a single contributor's account. The attacker used a spoofed npm 2FA reset email to capture credentials and a live TOTP code, then published malicious versions to 19+ packages with a combined 2+ billion weekly downloads. The payload was a browser-targeted cryptostealer — it hooked window.ethereum and intercepted wallet transactions for Bitcoin, Ethereum, and Solana.
The packages compromised included:
- chalk (299M weekly downloads)
- ansi-styles
- strip-ansi
- wrap-ansi
- ansi-regex
Every single one of these is in Ink's dependencies object in package.json. For a window before npm yanked the malicious versions, any npm install in a project using Ink would pull compromised code.
The malicious payload targeted browser environments specifically — a pure Node.js CLI would download the compromised code but the cryptostealer wouldn't execute without window.ethereum. That said, for the roughly two hours the packages were live, 1-in-10 cloud environments pulled the malicious versions. The supply chain was compromised whether or not the payload fired.
This is not a theoretical concern about supply chain risk. This attack actually happened to Ink's exact dependency graph.
A Rust application's dependencies are compiled from source, pinned via Cargo.lock, and individually auditable with cargo audit. A Go application's dependencies are versioned, vendorable, and do not have a culture of single-function micro-packages. Neither ecosystem has had a supply chain incident at this scale.
The IME Problem
Claude Code causes 200-500ms input latency for Japanese, Chinese, and Korean users. The root cause traces to Ink's TextInput component making incorrect assumptions about IME composition in terminal environments — a problem that arises from routing terminal input through a React event model designed for browser DOM events.
To Anthropic's credit, they rewrote their renderer — replacing Ink's full-redraw model with a custom cell-diffing engine that emits minimal ANSI escape sequences, reducing flicker by roughly 85%. But Ink remains in the dependency tree (patched for IME via Issue #3045), and the underlying architecture is still React/TypeScript/Node.js. The renderer improved; the platform costs did not.
Anthropic's stated reason for the stack choice: "Around 90% of Claude Code is written with Claude Code" — TypeScript and React are technologies the model already knows. The tooling chose the stack that the AI could maintain. Whether this justifies the runtime cost is the question.
Claude Code ships as a single 7.6 MB bundled cli.js with no source maps. The only workaround for IME issues is composing text in an external editor and pasting.
Terminal applications built with native TUI libraries — ratatui, crossterm, bubbletea — handle IME correctly by default, because they use the terminal's own input handling rather than reimplementing it through an abstraction designed for a different platform.
The Gatsby Benchmark
This is not just a CLI problem. When Gatsby used Ink for rendering progress bars during builds, the overhead was directly measured:
- Ink enabled: 661 seconds average build time
- Ink without progress bars: 583 seconds
- Yurnalist (non-React logger): 503 seconds
158 seconds of overhead from using Ink's rendering model for progress bars. Vadim Demedes himself concluded: "the heavy part is happening when output is generated in Ink, not when it was rendered." The reconciliation and layout computation is the bottleneck.
What You Should Use Instead
| Ratatui (Rust) | Bubbletea (Go) | Textual (Python) | |
|---|---|---|---|
| Architecture | Immediate-mode, buffer diffs | Elm Architecture, pure functions | Segment tree, dirty-region tracking |
| Rendering | Sub-millisecond, 60+ FPS | Goroutine-based concurrency | 120 FPS (vs curses' 20 FPS) |
| Binary | Single static file, no runtime | Single static file, no runtime | Requires Python |
| Memory | 30-40% less than Go (no GC) | ~130 MB for a full AI CLI | Better than React |
| Modals/Overlays | Yes | Yes | Yes |
| IME Support | Terminal-native | Terminal-native | Terminal-native |
| React 19 compat | N/A | N/A | N/A |
Ink does not support modals or overlays — a fundamental UI pattern — because the abstraction breaks down when you try to z-order in a character grid. Every library in the table above handles this correctly.
Ink does not work with React 19 as of early 2025. If you build your CLI with Ink, your React version is frozen until Ink ships a compatibility update.
Even Textual, written in Python — a language not known for performance — achieves better terminal rendering than React/Ink by using the right primitives (segment trees, dirty-region tracking) instead of porting a browser rendering model to a character grid. (Note: Textualize the company shut down in May 2025, but Textual continues as open source under its creator Will McGugan.)
The Obfuscation Non-Argument
JavaScript terminal applications — even bundled, minified, and obfuscated — ship source code. The obfuscation is a speed bump. Standard tools exist to deobfuscate, pretty-print, and rename variables. Researchers have demonstrated extracting function names from Claude Code's "heavily obfuscated" 7.6 MB bundle using strings cli.js | grep.
A compiled Rust binary ships machine code. Without debug symbols (stripped in release builds), reconstructing logic requires disassembly and manual reverse engineering — orders of magnitude harder.
If you are building a commercial CLI with proprietary logic and your IP protection strategy is JavaScript minification, you do not have an IP protection strategy.
The Philosophical Point
The React component model was designed to solve a real problem: managing complex, interactive UI state in a browser environment where DOM mutations are expensive and unpredictable. The virtual DOM, the reconciler, the fiber architecture — these exist because the browser is hostile to direct manipulation.
A terminal is not hostile. A terminal is a byte stream. You write characters, they appear. There is no layout engine between you and the output. There is no reflow. There is no repaint cascade.
Using React to render a terminal UI is like using a crane to hang a picture frame. The crane works. It can position the frame. It can even adjust it if you want it moved. But you are deploying heavy machinery to solve a problem that requires a nail and a hammer.
The nail and hammer compile to a 4 MB binary, start in 50 milliseconds, and use 15 MB of RAM.
The crane requires Node.js, 25+ npm packages including a C++ flexbox engine compiled to WASM, reserves 32 GB of virtual memory, starts in 4 seconds, and five of its load-bearing cables were briefly replaced with ones that steal cryptocurrency. (The smartest team using the crane rewrote the hoist mechanism — and it's still a crane.)
Use the hammer.