"Building a Node TUI agent without dying of OOM: 3 production crashes and the patterns I kept"
A terminal AI agent in Node grows to V8's ~2GB heap ceiling in hours. The 3 crashes I hit and the compaction, cleanup, and redaction pattern I ship now.
Straight answer, before the story: a terminal (TUI) agent written in Node dies of OOM because V8's default old-space heap on 64-bit tops out around ~2GB, and an agent that keeps the whole conversation history in memory hits that ceiling within a few hours of a real session. The fix is not --max-old-space-size=8192 — that only delays the crash. It's compacting history, cleaning up the SSE/WebSocket subscribers nobody removes, redacting secrets before text enters the history, and rendering only the terminal lines that are visible. I hit three distinct crashes building an AI CLI that ran long sessions, and each had a different root cause. This post is the postmortem of all three.
I'm Ulisses, founder of Hens. We ship WhatsApp bots, Flutter apps, and automation — and an AI CLI running in a client's terminal is pure automation: it stays open all day, streams tokens non-stop, and paints a React UI inside 80 terminal columns. Exactly the kind of long-lived process that surfaces every memory leak a short HTTP request would hide.
Why a terminal agent is the worst case for memory in Node
A normal web server lives on short requests. Each request allocates, responds, and the garbage collector cleans up. The process rarely spends more than a few minutes holding the same large object.
A terminal agent is the opposite. The process stays alive for hours. It keeps the entire conversation history — every prompt, every model response, every tool result — to send back to the model on the next turn. That's an array that only grows. Add a UI that re-renders on every streamed token, a persistent connection to the model gateway, and closures that hang around, and you have the three ingredients of an OOM: a long-lived object, high allocation per second, and a connection that pins closures.
When I shipped the first version of this CLI, it crashed. Not in testing. In production, on the client's machine, after about 5–6 hours of continuous use. The stack trace was always the same: FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. Three different causes hid behind that one message. Here they are.
Crash 1: Node's default heap isn't what you think
My first instinct was everyone's wrong instinct: "Node's low on memory, raise the limit." I ran with --max-old-space-size=8192 and went to sleep thinking it was fixed. Next day it crashed again — after 11 hours instead of 6. I hadn't fixed anything. I'd just bought time and, worse, buried the leak under a higher ceiling.
The number that matters: V8's old-space heap on 64-bit is not unlimited by default, and not as high as you'd guess. In a cgroup environment (container), Node 20+ is "container-aware" and picks a ceiling proportional to available memory. Red Hat Developer's table is explicit:
| Container memory | Node max heap |
|---|---|
| 1 Gi | 520 Mi |
| 2 Gi | 1,040 Mi |
| 4 Gi | 2,080 Mi |
| 5+ Gi | 2,080 Mi (capped) |
The rule, in their words: "The Node.js heap size is 50% of the container's size, up to 4 Gi. After 4 Gi, the maximum heap value naturally levels out at 2 Gi" (Red Hat Developer, 2025). So even on a 16GB box, your agent's default heap stubbornly sits at ~2GB. I measured mine with v8.getHeapStatistics().heap_size_limit (Node.js Docs, 2026) and got a round ~2GB.
Now do the math. A conversation history with large tool results (picture the model reading three 200KB files) grows tens of MB per turn easily. In a real work session, across dozens of turns, you hit 2GB before the end of the day. The CLI had no leak there — it was doing exactly what I asked: storing everything. The fix isn't a bigger heap. It's compaction: when history crosses a token threshold, you summarize the old turns into one short message and drop the rest.
const MAX_TOKENS = 120_000;
function maybeCompact(history) {
if (estimateTokens(history) < MAX_TOKENS) return history;
const recent = history.slice(-6); // latest turns, untouched
const old = history.slice(0, -6);
const summary = summarizeTurns(old); // 1 cheap model call
return [{ role: 'system', content: `Conversation summary so far:\n${summary}` }, ...recent];
}
Opinion, no hedging: raising --max-old-space-size on a long-lived agent is always the wrong answer. If the process lives for hours and the main object only grows, a higher ceiling just means a later crash and a harder-to-read heap dump. Treat it as a leak until you prove otherwise.
Crash 2: the subscriber that never dies
I fixed compaction and the CLI started lasting the day. But heap_size_limit in use kept creeping up in very long sessions — much slower, but creeping. I ran with node --inspect and took two heap snapshots 40 minutes apart. The diff pointed to monotonic growth of closures pinned to the model gateway client.
The cause is the oldest streaming bug in Node, and it isn't unique to my code — it's documented as the #1 leak in WebSocket servers (OneUptime, Jan 2026): you add a listener per message/connection and never remove it. In my case, the gateway client opened one SSE stream per turn, and each onMessage registered a subscriber. The subscriber was an arrow function — a closure capturing the full response body to parse incrementally. When the turn ended, the stream closed, but the subscriber stayed in the list. The GC couldn't collect the closure, and the closure pinned the body. Multiply by one turn per minute over 8 hours.
The bug in code, simplified:
// WRONG — every turn adds a subscriber that never leaves
gateway.on('message', (chunk) => {
buffer.push(chunk); // 'buffer' is pinned forever in this closure
render(parse(buffer));
});
The fix is trivial once you see it: keep the handler reference and remove it on close and error.
// RIGHT — named handler, removed at end of turn
function onChunk(chunk) {
buffer.push(chunk);
render(parse(buffer));
}
gateway.on('message', onChunk);
stream.once('close', () => gateway.off('message', onChunk));
stream.once('error', () => gateway.off('message', onChunk));
The symptom that would've saved me hours: Node warns you. When an EventEmitter passes 10 listeners, it prints MaxListenersExceededWarning: Possible EventEmitter memory leak detected. I had silenced that warning months earlier because it "showed up too much in the logs." Silencing the leak warning because it warns about a leak — that was the dumbest move in the whole saga.
Crash 3: the secret that landed in history (and why compaction isn't enough)
This one didn't crash on memory — it crashed my trust in compaction. After fixing the first two, I audited what exactly went into the compacted history and found, buried in a tool result, one of the client's API keys. The agent had run a command that printed an environment variable, the output entered history, and history is precisely what we send back to the model every turn and summarize during compaction. The secret had leaked into the prompt and been baked into the summary.
This is not an exotic edge case. Per Cyberhaven's AI Adoption & Risk Report cited in 2026 analysis, 39.7% of all enterprise AI interactions involve sensitive data — and much of it flows through unmanaged channels. A terminal agent with shell access is a leak-generating machine, because it reads .env files, runs printenv, does git config.
The technical point almost everyone gets wrong: redaction has to happen BEFORE text enters history, not before it's sent to the model. If you redact only on the way out, the secret is already compacted into the summary, already persisted to disk if you save sessions, and already sent on the previous turn. Redact at the input boundary — the moment a tool's output is captured:
const REDACT = [
/\b(sk|pk|rk)_[A-Za-z0-9]{20,}\b/g, // Stripe/OpenAI-style keys
/\bghp_[A-Za-z0-9]{36}\b/g, // GitHub token
/\bAKIA[0-9A-Z]{16}\b/g, // AWS access key
];
function captureToolOutput(raw) {
let clean = raw;
for (const re of REDACT) clean = clean.replace(re, '‹redacted›');
history.push({ role: 'tool', content: clean }); // history never sees the secret
}
Tools like prompt-sentinel do this with regex + NER and a post-response restore step (Gravitee, 2026). For a terminal agent, I prefer irreversible redaction on input — I don't want the restore map existing anywhere. It's less convenient and it's the right call when the data is a credential, not PII that needs to come back.
The terminal freezing: render only what's visible
All three crashes were memory. But there was a fourth problem that looked like OOM and wasn't: on slow terminals (SSH over a bad link, or Terminal.app with a huge scrollback), the CLI froze on every keystroke. CPU went to 100%, input stuttered, and it looked like the process was dying.
The cause was rendering. A React TUI (like Ink) reconciles and repaints the whole tree on every state change. Ink already throttles renders to a ~32ms minimum and, since v7 (April 2026), has an incremental render mode that only rewrites the lines that changed. Even so, two of my patterns were killing the frame rate:
1. Re-render on every keystroke, including on resize. Each key fired a resize/layout RPC. The fix was a 50ms trailing-edge debounce — you wait 50ms after typing stops before recomputing layout, so a burst of keys collapses into a single recompute:
function debounce(fn, ms = 50) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
const onResize = debounce(recomputeLayout, 50);
50ms is the number that worked: fast enough to feel instant, slow enough to collapse a typing burst into one render.
2. Rendering the entire history every time. A 3-hour session has thousands of output lines. Repainting all of them on every token is absurd — the terminal only shows 40. I switched to a column-aware "virtual history": compute how many lines fit the current viewport and render only those, with the right offsets for scroll. In my long sessions that cut **70% of rendering CPU** (measured in my own profiling, not a vendor number). The terminal isn't the DOM — it doesn't get a virtual viewport for free, so you have to build yours.
The pattern I ship now
After the three crashes, every terminal agent Hens ships is born with this checklist. It's not optional:
- Compaction by token threshold, not by message count. Summarize the old turns in one cheap call when it crosses the limit (I used ~120k tokens). Never let history grow unbounded "because there's spare RAM" — V8 caps at ~2GB before your RAM runs out.
- Named handlers +
off()oncloseanderrorfor every SSE/WebSocket stream. Never an anonymous arrow in.on()of a resource that closes. And never silenceMaxListenersExceededWarning. - Irreversible redaction on input. Secret regex applied the moment tool output is captured, before it enters history — because compaction and session persistence see the history, not the output.
- Drain pending state before every model call, not just at startup. If the user switches models mid-conversation, that change has to propagate before the next turn — otherwise you send the turn to the wrong model. Drain the "steer" queue at the top of every call.
- Render only the visible + 50ms resize debounce. Column-aware virtual history; no repainting thousands of lines the terminal doesn't show.
- A baseline heap snapshot in CI. I run a synthetic 200-turn session and compare final
heap_size_limitused against a ceiling. If it went up, it's a leak regression and the build breaks.
What I'd do differently
If I started over, I'd instrument memory before shipping, not after the first crash on the client's machine. A setInterval logging process.memoryUsage().heapUsed every minute would've shown the monotonic growth curve in the first hour of testing — instead of me finding out through an OOM stack trace that only appeared after 6 hours of real use. The feedback loop was too long because the symptom took hours to show, and you don't reproduce a memory crash by hitting F5.
And I wouldn't have silenced that warning. Node told me I had a listener leak, in those exact words, months before the first OOM. I chose not to listen because the log was noisy. The lesson I carry into every Hens project: when the runtime warns of a leak, the noise is the signal.
Sources
- Node.js 20+ memory management in containers — Red Hat Developer, 2025
- Understanding and Tuning Memory — Node.js Docs, 2026
- How to Fix 'Memory Leak' Issues in WebSocket Servers — OneUptime, Jan 2026
- PII Redaction for LLMs in 2026 (cites the Cyberhaven AI Adoption & Risk Report) — PC Tech Magazine, Jun 2026
- How to Prevent PII Leaks in AI Systems — Gravitee, 2026
- Ink — React for interactive command-line apps (v7, April 2026)
Want an app like this for your business?
Hens builds Flutter apps, AI-powered WhatsApp bots and custom backoffice systems — with the same rigor we apply to our own products. From MVP to production. First call is direct, no form.
Talk on WhatsApp →