Memory Leaks: Native RssAnon grows ~3 MB/s with 1000 keyed <text> in <scrollbox> under 10Hz ring updates; JS heap and node count stay flat
Author: codehzCreated Sep 11, 2026Updated Sep 11, 2026
Environment
- @opentui/core / @opentui/react 0.5.11 (also 0.5.4)
- bun 1.4.2, linux x64
- createTestRenderer (bufferedOutput: "memory")
What
1000 <text> children in a sticky <scrollbox>. Every 100ms drop the oldest row and append a
new one with a monotonic React key.
JS heap plateaus (~+3MB). renderer.root child count stays 1003. /proc/self/status
RssAnon climbs 3 MB/s (+70MB / 15s).
Idle (same 1000 nodes, 10Hz renderOnce, no mutation): RssAnon ~0.
Stable keys (key=slot, only last line text changes): ~9× slower, roughly plateaus.
Repro
#!/usr/bin/env bun
/**
* Minimal repro: native RSS grows while JS heap and renderable count stay flat.
*
* bun add @opentui/[email protected] @opentui/[email protected] react
* bun opentui-native-rss-repro.tsx
* bun opentui-native-rss-repro.tsx --idle
*
* ring (default): 1000 <text> in <scrollbox>, 10Hz ring buffer, key=monotonic id
* idle: same 1000 lines, no mutation, still 10Hz native frames
*
* Observed @ 0.5.4 and 0.5.11 (linux x64, bun 1.4):
* ring 15s RssAnon +~70MB (~3 MB/s) heap +~3MB nodes=1003 constant
* idle 15s RssAnon ~0
*/
/** @jsxImportSource @opentui/react */
import { createTestRenderer } from "@opentui/core/testing";
import { createRoot, flushSync } from "@opentui/react";
import { useRef, useState } from "react";
import corePkg from "@opentui/core/package.json";
const CAPACITY = 1000;
const INTERVAL_MS = 100;
const SECONDS = Number(process.argv.includes("--seconds") ? process.argv[process.argv.indexOf("--seconds") + 1] : 15);
const IDLE = process.argv.includes("--idle");
type Row = { id: number; text: string };
function seed(): Row[] {
return Array.from({ length: CAPACITY }, (_, i) => ({
id: i + 1,
text: `world:tick:sync tick=${i + 1}`,
}));
}
function push(prev: Row[], id: number): Row[] {
const next = prev.slice(1);
next.push({ id, text: `world:tick:sync tick=${id}` });
return next;
}
function App({ driver }: { driver: { tick?: () => void } }) {
const [rows, setRows] = useState(seed);
const nextId = useRef(CAPACITY + 1);
driver.tick = () => {
const id = nextId.current++;
setRows((prev) => push(prev, id));
};
return (
<box width={120} height={40}>
<scrollbox stickyScroll stickyStart="bottom" height="100%" scrollY overflow="hidden">
{rows.map((row) => (
<text key={row.id} wrapMode="none" truncate>
{row.text}
</text>
))}
</scrollbox>
</box>
);
}
function parseStatus(text: string) {
let rssKb = 0;
let rssAnonKb = 0;
for (const line of text.split("\n")) {
if (line.startsWith("VmRSS:")) rssKb = Number.parseInt(line.replace(/\D+/g, ""), 10) || 0;
if (line.startsWith("RssAnon:")) rssAnonKb = Number.parseInt(line.replace(/\D+/g, ""), 10) || 0;
}
return { rssKb, rssAnonKb };
}
function countNodes(node: { getChildren?: () => unknown[] }): number {
return 1 + (node.getChildren?.() ?? []).reduce((n, c) => n + countNodes(c as { getChildren?: () => unknown[] }), 0);
}
const test = await createTestRenderer({
width: 120,
height: 40,
useMouse: false,
gatherStats: true,
consoleMode: "disabled",
screenMode: "main-screen",
bufferedOutput: "memory",
});
const root = createRoot(test.renderer);
const driver: { tick?: () => void } = {};
flushSync(() => root.render(<App driver={driver} />));
await test.renderOnce();
const started = performance.now();
console.log(`# opentui ${corePkg.version} bun ${Bun.version} ${process.platform} ${process.arch} mode=${IDLE ? "idle" : "ring"}`);
console.log("t_s\trss_kb\trss_anon_kb\theap_kb\tnodes");
const sample = async (t: number) => {
Bun.gc(true);
const mu = process.memoryUsage();
const st = parseStatus(await Bun.file("/proc/self/status").text());
console.log(`${t.toFixed(1)}\t${st.rssKb}\t${st.rssAnonKb}\t${Math.round(mu.heapUsed / 1024)}\t${countNodes(test.renderer.root)}`);
};
await sample(0);
let last = -1;
while (performance.now() - started < SECONDS * 1000) {
const t0 = performance.now();
if (!IDLE) flushSync(() => driver.tick?.());
await test.renderOnce();
const t = (performance.now() - started) / 1000;
const bucket = Math.floor(t);
if (bucket !== last && bucket > 0) {
last = bucket;
await sample(t);
}
const wait = INTERVAL_MS - (performance.now() - t0);
if (wait > 1) await Bun.sleep(wait);
}
await sample((performance.now() - started) / 1000);
root.unmount();
test.renderer.destroy();
bun add @opentui/[email protected] @opentui/[email protected] react
bun opentui-native-rss-repro.tsx
bun opentui-native-rss-repro.tsx --idleExpected
After warmup, RssAnon should be bounded like idle / stable-key.
Actual
ring mode RssAnon linear. Looks like Zig TextBuffer/Yoga pages are not returned when a keyed is unmounted (1 insert + 1 delete per tick).
Source: anomalyco/opentui