JavaScript heap out of memory в Node: что делать
Эта ошибка приходит на самом неподходящем моменте. Деплой собирается полчаса, и в самом конце выпадает:
<--- Last few GCs --->
[1234:0x55c1abcd] 189345 ms: Mark-sweep 2046.5 (2058.4) -> 2046.4 (2058.4) MB
FATAL ERROR: Reached heap limit Allocation failed -
JavaScript heap out of memoryЭто Node сообщает, что сборщику мусора уже некуда расти, и виртуальная машина V8 завершает процесс. По умолчанию у Node лимит heap'а — порядка 4 ГБ на 64-битных системах (раньше было около 1.5 ГБ, но в современных версиях лимит подняли). Когда сборка большая или приложение держит много данных в памяти — упираешься.
Первый рефлекс — поднять лимит
Через переменную окружения:
NODE_OPTIONS="--max-old-space-size=8192" pnpm buildЗдесь 8192 — это размер старой генерации в мегабайтах, грубо говоря, верхняя граница heap'а. Иногда это правильное лекарство: сборка крупного Next-проекта или большой TypeScript-компиляции реально требует больше памяти, особенно с source maps.
В CI я добавляю эту переменную в job-окружение, чтобы не редактировать каждый package.json:
env:
NODE_OPTIONS: --max-old-space-size=8192На сервере с 4 ГБ ОЗУ ставить 8 ГБ heap'а бессмысленно — Node будет свопиться и работать ещё медленнее. Сначала проверь, сколько памяти физически есть.
Когда это симптом, а не причина
Если процесс рантайма (а не сборка) падает с heap out of memory — это почти всегда утечка. Поднимешь лимит — упадёт через два часа вместо одного. Лечить надо находя источник.
Признаки утечки
- память приложения растёт линейно при равномерной нагрузке;
- после каждого деплоя ОК, через сутки — плохо;
- график RSS в Grafana похож на пилу с растущей вершиной.
На таких симптомах поднимать --max-old-space-size — самообман. Идти искать.
Как снять heap snapshot
Node может сделать дамп heap'а на диск — потом открываешь его в Chrome DevTools, ходишь по объектам и ищешь, кого слишком много.
Самый простой способ — сигнал:
kill -USR2 <pid>В свежих Node это создаёт файл Heap.<timestamp>.heapsnapshot. Скачиваешь его на свой ноут, открываешь в DevTools (Performance > Memory > Load), смотришь, какой класс/конструктор внезапно занимает половину памяти.
Можно делать snapshots программно:
import { writeHeapSnapshot } from 'v8';
process.on('SIGUSR2', () => {
const file = `/tmp/heap-${Date.now()}.heapsnapshot`;
writeHeapSnapshot(file);
console.log(`Heap dumped: ${file}`);
});Делаешь два снимка с разрывом 5–10 минут под нагрузкой и сравниваешь — что приросло.
Типичные источники утечек
Подписки на эмиттеры без отписок
Сервис вешает обработчик на event emitter и забывает отписаться при завершении запроса. Через сутки в emitter висят 10000 closures, держащих ссылки на запросы.
// плохо
function handle(req, res) {
bus.on('event', (e) => res.write(e));
}
// хорошо
function handle(req, res) {
const onEvent = (e) => res.write(e);
bus.on('event', onEvent);
res.on('close', () => bus.off('event', onEvent));
}Глобальные кэши без TTL
Простой const cache = new Map(), в который кладут результаты запросов «чтобы быстрее». Никто не чистит. К концу месяца там пол-базы. Лечится либо TTL-кэшем (lru-cache), либо размером (max items).
Замыкания в Promise.all над большими коллекциями
Я видел это много раз: кто-то делает Promise.all(rows.map(processRow)) над 200 000 строк. Все 200 000 promise'ов и их замыкания живут в памяти, пока последний не завершится.
Лекарство — обрабатывать пачками:
for (const batch of chunk(rows, 100)) {
await Promise.all(batch.map(processRow));
}Стримы без backpressure
Читаешь файл стримом, парсишь, пишешь в БД. Если БД медленнее, чем чтение, очередь задач между ними растёт. К моменту OOM в очереди уже миллион записей. Помогает pipeline с правильной обработкой backpressure или явные паузы стрима.
Что делать прямо сейчас, если падает на проде
В порядке от быстрого к серьёзному:
- Перезапустить, чтобы вернуть сервис в строй. Это не починка, это пожарный шланг.
- Увеличить лимит на heap до того, что физически есть на машине, — выиграть время, чтобы спокойно искать причину.
- Снять snapshot до перезапуска, если успеваешь. Это золото для разбора.
- Включить мониторинг RSS и heap — Prometheus exporter в Node умеет это из коробки.
- Найти и починить утечку.
Особенности сборки
Если ошибка приходит именно во время next build или tsc, причина обычно не в утечке, а в реальной потребности. Длинные компиляции с большими типами и source maps жрут много. Я в Next-проектах с тяжёлым TypeScript уже не удивляюсь, когда нужно 6–8 ГБ.
В package.json:
{
"scripts": {
"build": "NODE_OPTIONS='--max-old-space-size=8192' next build"
}
}На Windows такая запись не подхватится, в кросс-платформенных проектах удобно через cross-env:
{
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build"
}
}Что забрать с собой
- OOM на сборке — обычно лечится повышением лимита, если памяти на машине достаточно.
- OOM на рантайме — это утечка, и лимит просто отодвигает падение во времени.
- Heap snapshots — твой главный инструмент, не игнорируй их.
- Метрики RSS и heap живут в Node бесплатно, ставь их в мониторинг сразу.
OOM-сообщение не страшное, если научиться читать симптомы. Большинство инцидентов у меня в практике решались за пару часов работы со снимками heap'а — без сложных инструментов и без чёрной магии.