Утечки памяти в Node.js: поиск и лечение
Процесс Node растёт до 1,5 ГБ за сутки, потом падает с heap out of memory. Разбираем, как снять heap snapshot, найти удерживаемые объекты и закрыть типовые источники утечек.

Классическая картина: сервис на Node.js живёт полдня нормально, потом RSS ползёт вверх, latency растёт, и процесс падает с FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. Перезапуск помогает на несколько часов. Это не баг рантайма — почти всегда это ссылки, которые ваш код держит и не отпускает. Ниже — как найти конкретный объект, который течёт, и что с ним делать. Всё проверено на Node.js 20.x и 22.x, инструменты те же и для 18.x.
Сначала отличить утечку от нормального роста
V8 не отдаёт память ОС сразу после GC — RSS может держаться высоким, хотя куча внутри свободна. Поэтому «RSS вырос» само по себе ещё не утечка. Утечка — это когда размер живой кучи после полной сборки мусора монотонно растёт от цикла к циклу.
Быстрая проверка без профайлера — запустить процесс с флагом и логировать метрики:
// метрики каждые 30 секунд, Node 20.x
setInterval(() => {
const m = process.memoryUsage();
console.log(JSON.stringify({
rss: Math.round(m.rss / 1048576), // резидентная память, МБ
heapUsed: Math.round(m.heapUsed / 1048576), // занято в куче, МБ
external: Math.round(m.external / 1048576) // буферы вне кучи, МБ
}));
}, 30000);
Если heapUsed после каждого пика GC оказывается выше предыдущего минимума и так часами — это утечка кучи. Если растёт только external при спокойном heapUsed — течёт что-то вне V8: Buffer, нативный аддон, незакрытые дескрипторы.
Правило, которое экономит часы: сравнивайте не пики, а провалы после сборки мусора. Пики шумят, минимумы говорят правду.
Форсировать GC, чтобы увидеть чистую картину
Чтобы минимумы были честными, запустите процесс с --expose-gc и вызывайте сборку перед замером:
// запуск: node --expose-gc app.js
if (global.gc) {
setInterval(() => {
global.gc();
const heapUsed = process.memoryUsage().heapUsed;
console.log('heapUsed after gc, MB:', Math.round(heapUsed / 1048576));
}, 60000);
}
--expose-gc — только для диагностики. В проде ручной вызов GC мешает планировщику V8 и делает latency хуже, а не лучше.
Снять heap snapshot и сравнить два
Один снимок кучи показывает, что лежит в памяти сейчас. Но найти утечку по одному снимку тяжело — там тысячи объектов, и все выглядят нужными. Работает сравнение двух снимков: сделать первый, дать нагрузке поработать, сделать второй и посмотреть, что появилось и не исчезло.
- Запустите процесс с инспектором:
node --inspect app.js. В консоли появится строкаDebugger listening on ws://127.0.0.1:9229/.... - Откройте в Chrome адрес
chrome://inspect, нажмите Open dedicated DevTools for Node. Процесс появится в списке Remote Target. - Вкладка Memory → Heap snapshot → Take snapshot. Это снимок №1.
- Прогоните типовую нагрузку — например, 10 000 запросов к подозрительному эндпоинту через
autocannonили свой скрипт. - Сделайте снимок №2.
- В выпадающем списке над таблицей выберите режим Comparison и базой поставьте снимок №1. Теперь видно колонку Delta — сколько объектов каждого типа добавилось и не освободилось.
Сортируйте по колонке Delta по убыванию. Тип с большой положительной дельтой и есть кандидат. Кликните на строку — внизу в панели Retainers будет цепочка ссылок, которая держит объект в памяти. Именно она отвечает на вопрос «почему GC его не собрал».
Как читать Retainers
Цепочка удержания читается снизу вверх: от корня GC (обычно это глобальный объект, замыкание или запись в Map) к вашему объекту. Ищите в ней знакомые имена — название вашего кеша, массива, обработчика. Часто корнем оказывается (closure) — значит объект держит замыкание, которое живёт дольше, чем должно.
Пять источников, на которые приходится большинство утечек
Прежде чем лезть глубоко, проверьте это — статистически здесь прячется большая часть проблем.
| Источник | Как выглядит в снимке | Что делать |
|---|---|---|
Глобальный кеш без лимита (обычный Map или {}) | Растущий Map / Object с тысячами ключей | Ограничить размер (LRU), задать TTL |
Подписки на события без off() | MaxListenersExceededWarning в логах, много замыканий | Снимать слушатель при завершении жизненного цикла |
Таймеры setInterval, которые не очищаются | Timeout-объекты в цепочке удержания | Хранить id и вызывать clearInterval |
| Замыкание, захватившее большой объект | Retainer вида (closure) над крупным контекстом | Не захватывать лишнее, обнулять ссылки после использования |
| Накопление в модульной области (массив на верхнем уровне) | Массив, растущий с каждым запросом | Не хранить состояние запроса в области модуля |
Классика: слушатель, который не сняли
Пример, который течёт: на каждый входящий запрос вешается слушатель на долгоживущий эмиттер, но никто его не снимает.
// ТЕЧЁТ: на каждый запрос новый слушатель, старые остаются
const { EventEmitter } = require('node:events');
const bus = new EventEmitter(); // живёт всё время работы процесса
app.get('/subscribe', (req, res) => {
const onData = (payload) => res.write(JSON.stringify(payload));
bus.on('data', onData);
// слушатель не снимается — bus держит onData, onData держит res
});
Каждый res удерживается через замыкание onData, которое живёт в bus. Тысяча запросов — тысяча висящих ответов. Через несколько сотен подписок Node напишет в лог MaxListenersExceededWarning — это первый сигнал.
// НЕ ТЕЧЁТ: снимаем слушатель при закрытии соединения
app.get('/subscribe', (req, res) => {
const onData = (payload) => res.write(JSON.stringify(payload));
bus.on('data', onData);
req.on('close', () => {
bus.off('data', onData); // освобождаем ссылку
});
});
Классика: кеш без границ
Обычный Map в роли кеша растёт вечно, если из него ничего не удалять. Ключи вроде id пользователя или URL накапливаются бесконечно.
// ТЕЧЁТ: ключи только добавляются
const cache = new Map();
function getUser(id) {
if (!cache.has(id)) cache.set(id, loadUserFromDb(id));
return cache.get(id);
}
Лечение — ограничить размер и время жизни. Можно вручную, но проще взять проверенную библиотеку с LRU-вытеснением. Актуальные имена пакетов и опции сверяйте в их документации на npm, потому что API у версий отличается.
Когда ключи — объекты: WeakMap
Если ключ кеша — сам объект (например, узел DOM в SSR или инстанс запроса), используйте WeakMap. Он не мешает сборщику собрать ключ: как только на объект-ключ не осталось других ссылок, запись из WeakMap исчезает автоматически. Для ключей-примитивов (строки, числа) WeakMap не подойдёт — там нужен обычный кеш с явной политикой вытеснения.
Утечки вне кучи: external и нативное
Если растёт external, а heapUsed стабилен, снимок кучи почти не поможет — объекты живут вне V8. Частые причины: незакрытые файловые дескрипторы и сокеты, накопление Buffer, утечка в нативном аддоне.
Проверьте активные хендлы, которые держат event loop:
// сколько активных дескрипторов держит процесс
// _getActiveHandles — внутренний метод, для диагностики, не для прода
console.log('active handles:', process._getActiveHandles().length);
console.log('active requests:', process._getActiveRequests().length);
Растущее число хендлов — знак незакрытых соединений с БД, HTTP-агентов без переиспользования пула или потоков без destroy(). Для нативных аддонов и глубокого разбора external нужен другой инструмент — например, профилирование через --heapsnapshot-near-heap-limit или сбор дампов через node:v8. Это отдельная тема, здесь мы её только обозначаем.
Снять снимок в проде, где нет DevTools
На боевом сервере Chrome не подключишь. Модуль v8 умеет писать снимок кучи в файл, который потом открывается в тех же DevTools локально:
const v8 = require('node:v8');
const fs = require('node:fs');
// пишем снимок в файл; можно повесить на сигнал
process.on('SIGUSR2', () => {
const filename = `heap-${Date.now()}.heapsnapshot`;
const stream = v8.getHeapSnapshot();
stream.pipe(fs.createWriteStream(filename));
console.log('snapshot written:', filename);
});
Отправляете процессу kill -SIGUSR2 <pid> в моменты низкой и высокой памяти, скачиваете оба .heapsnapshot и сравниваете в DevTools через Load. Снятие снимка приостанавливает процесс на время дампа — на большой куче это секунды, учитывайте это под нагрузкой.
Если не получилось
- Delta во всех типах примерно одинаковая, явного лидера нет. Скорее всего вы сняли второй снимок слишком рано — GC ещё не отработал. Форсируйте сборку через
--expose-gcперед снимком и повторите. - heapUsed стабилен, а процесс всё равно падает по памяти. Течёт external или нативная память — снимок кучи её не видит. Смотрите на
process.memoryUsage().externalи число активных хендлов. - Snapshot в проде роняет latency. Не снимайте под пиком трафика. Снимайте на реплике или в момент низкой нагрузки; для тяжёлых куч планируйте окно.
- DevTools не подключается к
--inspect. Порт 9229 закрыт или процесс за контейнером. Пробросьте порт (docker run -p 9229:9229) или снимайте снимок в файл черезv8.getHeapSnapshot. - Утечка воспроизводится только под реальным трафиком. Синтетическая нагрузка не бьёт по нужному пути. Снимайте два снимка прямо в проде с интервалом в час-два.
Что делать дальше
Настройте постоянный сбор heapUsed и rss в вашу систему метрик — Prometheus, Datadog, что используете, — с алертом на монотонный рост живой кучи за несколько часов. Так вы увидите утечку до падения, а не по факту OOM. Ограничьте кучу флагом --max-old-space-size, чтобы процесс падал предсказуемо и рано, а не съедал всю память ноды. И держите в CI хотя бы простой тест, который прогоняет эндпоинт N раз и проверяет, что heapUsed после GC не вырос выше порога — дешёвая страховка от регрессий.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: Node.js — Diagnostics: Memory (официальное руководство), Node.js API — v8.getHeapSnapshot()
Частые вопросы
У меня Node.js 18, а не 20. Инструменты те же?
Сделал два снимка, но Delta почти нулевая, хотя память растёт. Почему?
Можно ли снимать heap snapshot прямо на проде без остановки сервиса?
Чем WeakMap лучше обычного Map для кеша?
Поможет ли просто увеличить --max-old-space-size?
Growing RSS есть, а heapUsed стабилен — это точно утечка?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.