Skip to content

measureText / wrapText caches are unbounded and never evicted, leaking on every distinct string rendered #986

Description

@takecchi

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.tsconst cache = new Map()
https://github.com/vadimdemedes/ink/blob/main/src/wrap-text.tsconst 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions