Summary
measureText() and wrapText() each keep a module-level cache with no eviction, keyed by the full text. Every distinct string an app ever renders is retained for the lifetime of the process, so any app whose text changes over time (a streaming/typing indicator, a growing log, a clock, a progress line) leaks monotonically until it OOMs.
https://github.com/vadimdemedes/ink/blob/main/src/measure-text.ts — const cache = new Map()
https://github.com/vadimdemedes/ink/blob/main/src/wrap-text.ts — const cache = {}
Both are fed from the Yoga measure function (measureTextNode in dom.ts), so entries are added on every layout pass, not only on writes — renderThrottleMs throttles stdout, not layout. A single text node can add up to 3 permanent entries per layout (measureText(text), wrapText(text, width), measureText(wrappedText)).
Not a duplicate of #869: that one was React's dev-mode performance.measure() entries piling up in Node's unbounded buffer (fixed by NODE_ENV=production, react/react#35761). The numbers below were measured with NODE_ENV=production, so React's contribution is excluded.
Measurements
ink 7.1.1, node 24.15.0, NODE_ENV=production, retained heap = heapUsed delta after two forced gc() calls. Retention survives unmount(), which is what identifies it as module-level.
| Rendered each frame (4,000 frames, 118 columns) |
Retained |
| Text node whose content never changes (cache hits) |
~0.5 KB/frame |
| One ~100-character line, distinct every frame |
~1.7 KB/frame |
One 4,000-character <Text wrap="truncate-end">, distinct every frame |
~17.8 KB/frame |
At a modest 10 renders/s, the 4,000-character case is ~178 KB/s ≈ 640 MB/hour. Note the worst case is wrap="truncate-end": only ~118 cells are ever displayed, but the cache key is the whole 4,000-character string.
Real-world impact: this was one of two leaks that made codiva (a TUI that streams assistant output) die with Allocation failed - JavaScript heap out of memory after a day of use. Node's diagnostic report showed old_space at 4.2 GB with large_object_space at only 55 MB — i.e. millions of small retained objects (the cache keys and value objects), not one big allocation.
Reproduction
// leak.mjs — node --expose-gc leak.mjs
import {PassThrough} from 'node:stream';
import React from 'react';
import {render, Box, Text} from 'ink';
const used = () => {globalThis.gc(); globalThis.gc(); return process.memoryUsage().heapUsed};
const mb = n => `${(n / 1024 / 1024).toFixed(1)}MB`;
const stdout = new PassThrough();
stdout.columns = 120;
stdout.rows = 40;
stdout.on('data', () => {});
const view = n => React.createElement(
Box, {flexDirection: 'column', width: 118},
React.createElement(Text, {wrap: 'truncate-end'}, `${n} ${'x'.repeat(3994)}`),
);
const instance = render(view(0), {stdout, stdin: new PassThrough(), patchConsole: false});
const base = used();
for (let n = 1; n <= 4000; n++) {
instance.rerender(view(n));
await new Promise(r => setImmediate(r));
}
console.log('retained while mounted:', mb(used() - base));
instance.unmount();
console.log('retained after unmount:', mb(used() - base)); // ~the same → module-level
retained while mounted: 46.4MB
retained after unmount: 46.2MB
Suggested fix
Bound both caches — an LRU with a modest entry cap would keep the hit rate for the stable parts of a UI (they are touched every layout, so they stay warm) while making retention proportional to the visible UI instead of to everything ever rendered. Something like:
const CACHE_LIMIT = 4096;
const cache = new Map();
const remember = (key, value) => {
if (cache.size >= CACHE_LIMIT) {
cache.delete(cache.keys().next().value); // oldest first (insertion order)
}
cache.set(key, value);
};
Refreshing an entry on read (delete + set) makes it a true LRU, which matters when the number of live text nodes approaches the cap.
Happy to open a PR if that direction looks right.
Workaround for app authors
Don't hand Ink a long string that changes every frame: slice it to the width you actually display before rendering. wrap="truncate-end" does not help, because the cache key is the pre-truncation string. In codiva's case, clipping the streaming preview line to the display width cut it from 6,786 to 3,129 B/frame — and once the line exceeds the width the string stops changing, so it starts hitting the cache instead.
Environment
- ink 7.1.1
- node 24.15.0 (also reproduced on 22.22.3)
- macOS 15 (Darwin 24.5.0), Ghostty
Summary
measureText()andwrapText()each keep a module-level cache with no eviction, keyed by the full text. Every distinct string an app ever renders is retained for the lifetime of the process, so any app whose text changes over time (a streaming/typing indicator, a growing log, a clock, a progress line) leaks monotonically until it OOMs.https://github.com/vadimdemedes/ink/blob/main/src/measure-text.ts —
const cache = new Map()https://github.com/vadimdemedes/ink/blob/main/src/wrap-text.ts —
const cache = {}Both are fed from the Yoga measure function (
measureTextNodeindom.ts), so entries are added on every layout pass, not only on writes —renderThrottleMsthrottles stdout, not layout. A single text node can add up to 3 permanent entries per layout (measureText(text),wrapText(text, width),measureText(wrappedText)).Measurements
ink 7.1.1, node 24.15.0,
NODE_ENV=production, retained heap =heapUseddelta after two forcedgc()calls. Retention survivesunmount(), which is what identifies it as module-level.<Text wrap="truncate-end">, distinct every frameAt a modest 10 renders/s, the 4,000-character case is ~178 KB/s ≈ 640 MB/hour. Note the worst case is
wrap="truncate-end": only ~118 cells are ever displayed, but the cache key is the whole 4,000-character string.Real-world impact: this was one of two leaks that made codiva (a TUI that streams assistant output) die with
Allocation failed - JavaScript heap out of memoryafter a day of use. Node's diagnostic report showedold_spaceat 4.2 GB withlarge_object_spaceat only 55 MB — i.e. millions of small retained objects (the cache keys and value objects), not one big allocation.Reproduction
Suggested fix
Bound both caches — an LRU with a modest entry cap would keep the hit rate for the stable parts of a UI (they are touched every layout, so they stay warm) while making retention proportional to the visible UI instead of to everything ever rendered. Something like:
Refreshing an entry on read (
delete+set) makes it a true LRU, which matters when the number of live text nodes approaches the cap.Happy to open a PR if that direction looks right.
Workaround for app authors
Don't hand Ink a long string that changes every frame: slice it to the width you actually display before rendering.
wrap="truncate-end"does not help, because the cache key is the pre-truncation string. In codiva's case, clipping the streaming preview line to the display width cut it from 6,786 to 3,129 B/frame — and once the line exceeds the width the string stops changing, so it starts hitting the cache instead.Environment