"Construir TUI agent em Node sem morrer de OOM: 3 crashes em produção e os padrões que aprendi"
Um agente de terminal em Node cresce até o teto de heap do V8 (~2GB) em horas. Os 3 crashes que peguei e o padrão de compaction, cleanup e redação que uso hoje.
Resposta direta, antes da história: um agente de terminal (TUI) escrito em Node morre de OOM porque o heap old-space padrão do V8 em 64-bit trava em torno de ~2GB, e um agente que segura o histórico da conversa inteiro na memória chega nesse teto em algumas horas de sessão. A correção não é --max-old-space-size=8192 — isso só adia o crash. É compactar o histórico, limpar os subscribers de SSE/WebSocket que ninguém remove, redigir segredos antes de o texto entrar no histórico, e renderizar só as linhas visíveis do terminal. Eu peguei três crashes distintos construindo uma CLI de IA que rodava em sessões longas, e cada um tinha uma causa diferente. Esse post é o postmortem dos três.
Sou o Ulisses, fundador da Hens. A gente shippa bot de WhatsApp, app Flutter e automação — e uma CLI de IA que roda no terminal do cliente é automação pura: fica aberta o dia inteiro, processa uma stream de tokens sem parar, e desenha uma UI React dentro de 80 colunas de terminal. Justo o tipo de processo que expõe todo vazamento de memória que um request HTTP curtinho esconderia.
Por que um agente de terminal é o pior caso pra memória em Node
Um servidor web comum vive de requests curtos. Cada request aloca, responde, e o garbage collector limpa. O processo raramente passa de alguns minutos segurando o mesmo objeto grande.
Um agente de terminal é o oposto. O processo fica vivo por horas. Ele mantém o histórico inteiro da conversa — cada prompt, cada resposta do modelo, cada resultado de tool — pra mandar de volta pro modelo no próximo turno. Isso é um array que só cresce. Some a isso uma UI que re-renderiza a cada token que chega streamando, uma conexão persistente com o gateway do modelo, e você tem os três ingredientes de um OOM: objeto de vida longa, alocação alta por segundo, e conexão que segura closures.
Quando shippei a primeira versão dessa CLI, ela crashava. Não em teste. Em produção, na máquina do cliente, depois de umas 5–6 horas de uso contínuo. O stack trace era sempre o mesmo: FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. Três causas diferentes se escondiam atrás dessa mesma mensagem. Vou pelos três.
Crash 1: o heap padrão do Node não é o que você acha
Meu primeiro instinto foi o instinto errado de todo mundo: "o Node tá com pouca memória, sobe o limite". Rodei com --max-old-space-size=8192 e fui dormir achando que tava resolvido. No dia seguinte, crashou de novo — só que depois de 11 horas em vez de 6. Eu não tinha consertado nada. Só tinha comprado tempo, e ainda por cima escondido o vazamento debaixo de um teto mais alto.
O número que importa: o heap old-space do V8 em 64-bit não é ilimitado por padrão, e nem tão alto quanto você imagina. Em ambiente com cgroup (container), o Node 20+ é "container-aware" e escolhe o teto proporcional à memória disponível. A tabela do Red Hat Developer é explícita:
| Memória do container | Heap máximo do Node |
|---|---|
| 1 Gi | 520 Mi |
| 2 Gi | 1.040 Mi |
| 4 Gi | 2.080 Mi |
| 5+ Gi | 2.080 Mi (teto) |
A regra, nas palavras deles: "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). Ou seja: mesmo numa máquina de 16GB, o heap padrão do seu agente teima em ~2GB. Medi o meu com v8.getHeapStatistics().heap_size_limit (Node.js Docs, 2026) e deu ~2GB redondos.
Agora faz a conta. Um histórico de conversa com resultados de tool grandes (imagina o modelo lendo três arquivos de 200KB cada) cresce fácil dezenas de MB por turno. Numa sessão de trabalho de verdade, com dezenas de turnos, você bate 2GB antes do fim do expediente. A CLI não tinha vazamento nesse ponto — ela tava fazendo exatamente o que eu pedi: guardando tudo. O conserto não é heap maior. É compaction: quando o histórico passa de um limiar de tokens, você resume os turnos antigos em uma mensagem curta e joga o resto fora.
const MAX_TOKENS = 120_000;
function maybeCompact(history) {
if (estimateTokens(history) < MAX_TOKENS) return history;
const recent = history.slice(-6); // últimos turnos, intactos
const old = history.slice(0, -6);
const summary = summarizeTurns(old); // 1 chamada barata ao modelo
return [{ role: 'system', content: `Resumo da conversa até aqui:\n${summary}` }, ...recent];
}
Opinião, sem meio-termo: subir --max-old-space-size num agente de vida longa é sempre a resposta errada. Se o processo vive horas e o objeto principal só cresce, um teto maior só significa um crash mais tarde e um heap dump mais difícil de ler. Trate como vazamento até provar o contrário.
Crash 2: o subscriber que nunca morre
Consertei a compaction e a CLI passou a durar o dia. Mas o heap_size_limit continuava subindo devagar em sessões muito longas — bem mais devagar, mas subia. Rodei com node --inspect e tirei dois heap snapshots com 40 minutos de diferença. O diff apontou um crescimento monótono de closures presas ao cliente do gateway do modelo.
A causa é o clássico dos clássicos de streaming em Node, e não é exclusivo do meu código — é documentado como o vazamento nº 1 em servidores WebSocket (OneUptime, jan/2026): você adiciona um listener a cada mensagem/conexão e nunca remove. No meu caso, o cliente do gateway abria um stream SSE por turno, e cada onMessage registrava um subscriber. O subscriber era uma arrow function — uma closure que capturava o body inteiro da resposta pra fazer o parse incremental. Quando o turno acabava, o stream fechava, mas o subscriber continuava na lista. O GC não podia coletar a closure, e a closure segurava o body. Multiplica por um turno a cada minuto durante 8 horas.
O erro em código, simplificado:
// ERRADO — cada turno adiciona um subscriber que nunca sai
gateway.on('message', (chunk) => {
buffer.push(chunk); // 'buffer' fica preso pra sempre nessa closure
render(parse(buffer));
});
O conserto é banal quando você enxerga: guarde a referência do handler e remova no close e no error.
// CERTO — handler nomeado, removido no fim do turno
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));
O sintoma que teria me poupado horas: o Node avisa. Quando um EventEmitter passa de 10 listeners, ele cospe MaxListenersExceededWarning: Possible EventEmitter memory leak detected. Eu tinha silenciado esse warning meses antes, porque "aparecia demais no log". Silenciar o aviso de vazamento porque ele avisa de um vazamento — foi o erro mais burro da saga.
Crash 3: o segredo que foi parar no histórico (e por que compactar não basta)
Esse não crashou por memória — crashou minha confiança na compaction. Depois de resolver os dois primeiros, revisei o que exatamente ia dentro do histórico compactado e achei, no meio de um resultado de tool, uma chave de API do próprio cliente. O agente tinha rodado um comando que imprimiu uma variável de ambiente, o output entrou no histórico, e o histórico é justamente o que a gente manda de volta pro modelo a cada turno e resume na compaction. O segredo tinha vazado pro prompt e ficado gravado no resumo.
Isso não é um caso de borda exótico. Segundo o AI Adoption & Risk Report da Cyberhaven citado em análise de 2026, 39,7% de todas as interações de IA em empresas envolvem dados sensíveis — e boa parte passa por canais sem controle. Um agente de terminal com acesso a shell é uma máquina de gerar esses vazamentos, porque ele lê arquivos .env, roda printenv, faz git config.
O ponto técnico que quase todo mundo erra: redação tem que acontecer ANTES do texto entrar no histórico, não antes de mandar pro modelo. Se você redige só na saída, o segredo já foi compactado no resumo, já foi persistido em disco se você salva sessão, e já foi mandado no turno anterior. Redija na fronteira de entrada — no momento em que o output de uma tool é capturado:
const REDACT = [
/\b(sk|pk|rk)_[A-Za-z0-9]{20,}\b/g, // chaves estilo Stripe/OpenAI
/\bghp_[A-Za-z0-9]{36}\b/g, // token GitHub
/\bAKIA[0-9A-Z]{16}\b/g, // access key AWS
];
function captureToolOutput(raw) {
let clean = raw;
for (const re of REDACT) clean = clean.replace(re, '‹redacted›');
history.push({ role: 'tool', content: clean }); // o histórico nunca vê o segredo
}
Ferramentas como o prompt-sentinel fazem isso com regex + NER e um passo de restore pós-resposta (Gravitee, 2026). Pra um agente de terminal, eu prefiro redação irreversível na entrada — não quero o mapa de restore existindo em lugar nenhum. É menos conveniente e é a escolha certa quando o dado é credencial e não PII que precisa voltar.
O terminal travando: renderize só o que é visível
Os três crashes eram de memória. Mas tinha um quarto problema que parecia OOM e não era: em terminais lentos (SSH numa conexão ruim, ou o Terminal.app com um buffer gigante), a CLI travava a cada tecla digitada. A CPU ia a 100%, o input engasgava, e parecia que o processo tava morrendo.
A causa era renderização. Uma TUI React (tipo Ink) reconcilia e repinta a árvore inteira a cada mudança de estado. O Ink já limita renders a um mínimo de ~32ms e, desde a v7 (abr/2026), tem modo de render incremental que só reescreve as linhas que mudaram. Mesmo assim, dois padrões meus estavam matando o frame rate:
1. Re-render a cada keystroke, incluindo no resize. Cada tecla disparava um RPC de resize/layout. A correção foi um debounce trailing-edge de 50ms — você espera parar de digitar 50ms antes de recalcular o layout, então uma rajada de teclas vira um único recompute:
function debounce(fn, ms = 50) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
const onResize = debounce(recomputeLayout, 50);
50ms é o número que funcionou: rápido o bastante pra parecer instantâneo, lento o bastante pra colapsar uma rajada de digitação em um render.
2. Renderizar o histórico inteiro sempre. Uma sessão de 3 horas tem milhares de linhas de output. Repintar todas a cada token é absurdo — o terminal só mostra 40 delas. Passei a manter uma "virtual history" ciente das colunas: calculo quantas linhas cabem na viewport atual e só renderizo essas, com os offsets certos pro scroll. Nas minhas sessões longas isso cortou **70% do uso de CPU** de renderização (medido no meu profiling, não é número de vendor). O terminal não é o DOM — ele não tem viewport virtual de graça, então você tem que construir a sua.
O padrão que rodo hoje
Depois dos três crashes, todo agente de terminal que a Hens shippa nasce com esse checklist. Não é opcional:
- Compaction por limiar de tokens, não por número de mensagens. Resume os turnos antigos numa chamada barata quando passa do limite (usei ~120k tokens). Nunca deixa o histórico crescer ilimitado "porque tem RAM sobrando" — o V8 trava em ~2GB antes da sua RAM acabar.
- Handlers nomeados +
off()noclosee noerrorpra todo stream SSE/WebSocket. Nunca uma arrow anônima em.on()de um recurso que fecha. E nunca silencie oMaxListenersExceededWarning. - Redação irreversível na entrada. Regex de segredo aplicada no momento que o output da tool é capturado, antes de entrar no histórico — porque compaction e persistência de sessão veem o histórico, não a saída.
- Drain do estado pendente antes de cada chamada ao modelo, não só no startup. Se o usuário troca de modelo no meio da conversa, essa mudança tem que propagar antes do próximo turno — senão você manda o turno pro modelo errado. Drena a fila de "steer" no topo de cada chamada.
- Render só do visível + debounce de 50ms no resize. Virtual history ciente de colunas; nada de repintar milhares de linhas que o terminal não mostra.
- Um heap snapshot de baseline no CI. Rodo uma sessão sintética de 200 turnos e comparo
heap_size_limitusado no fim contra um teto. Se subiu, é regressão de vazamento e o build quebra.
O que eu faria diferente
Se fosse começar de novo, eu instrumentaria memória antes de shippar, não depois do primeiro crash na máquina do cliente. Um setInterval que loga process.memoryUsage().heapUsed a cada minuto teria mostrado a curva de crescimento monótona na primeira hora de teste — em vez de eu descobrir por um stack trace de OOM que só aparecia depois de 6 horas de uso real. O ciclo de feedback foi longo demais porque o sintoma demorava horas pra aparecer, e crash de memória você não reproduz apertando F5.
E eu não teria silenciado aquele warning. O Node me avisou que tinha um vazamento de listener, com essas palavras, meses antes do primeiro OOM. Eu escolhi não ouvir porque o log tava barulhento. A lição que levo pra todo projeto da Hens: quando o runtime avisa de um vazamento, o barulho é o sinal.
Fontes
- 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 (cita o 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, abr/2026)
Quer um app assim pro seu negócio?
A Hens constrói apps Flutter, bots WhatsApp com IA e backoffices sob medida — com o mesmo rigor dos nossos produtos próprios. Do MVP ao app em produção. Primeira conversa é direta, sem formulário.
Falar no WhatsApp →