Немного про "утечки" памяти: почему PM2 перезапускал Next.js‑воркеры и при чём здесь gcTime в TanStack Query
В одном из проектов начали "самопроизвольно" перезапускаться воркеры под управлением PM2. Снаружи это выглядело так: процесс какое-то время стабильно обслуживал запросы, затем завершался, PM2 поднимал новый экземпляр - и цикл повторялся. Сначала казалось, что проблема именно в рестартах, но логи быстро показали настоящую причину: PM2 убивал воркер при достижении заданного лимита памяти.
Первой реакцией было увеличить лимит. Это действительно продлило жизнь процессов, но не устранило первопричину: память продолжала расти и рано или поздно всё равно упиралась в новую границу. Значит, вопрос надо было формулировать иначе: что именно накапливается в процессе и почему оно не освобождается после обработки запросов.
Контекст и стек
Окружение было достаточно типовым, поэтому и найти причину хотелось "обычными" инструментами:
- Node.js 20
- Next.js 16 (App Router)
- TanStack Query
- SSR, при этом на каждый server render создаётся отдельный `QueryClient`
PM2 при этом следил за потреблением памяти каждого процесса и ориентировался на RSS. Важно понимать, что RSS - это не только JavaScript‑куча. Это вся физическая память процесса: heap V8, `Buffer`, нативные структуры, стеки, участки, выделенные библиотеками и т. п. Поэтому один график RSS не говорит, какие именно JS‑объекты "залипают". Он лишь честно сообщает: процесс пухнет.
Локальная воспроизводимость вместо гаданий
Чтобы не ловить симптомы в продакшене, я собрал воспроизводимый эксперимент в Docker с фиксированными условиями:
- лимит памяти контейнера: 1280 MiB
- CPU: 2
- один и тот же образ приложения
- один и тот же маршрут и профиль запросов
- 200 запросов за один прогон
Нагрузку генерировал скрипт со следующими параметрами:
- общее число запросов: 200
- concurrency: 2
- целевая скорость: 10 RPS
- timeout на запрос: 30 секунд
- фактическая скорость получалась примерно 9,8-9,93 RPS
Важная деталь генератора - чтение тела ответа до конца. Для этого использовалось `res.resume()`: без этого легко получить "ложную лёгкость", когда клиент формально "закрыл" запрос после заголовков, а сервер продолжает делать работу и держать ресурсы.
По каждому прогону я сохранял метрики: HTTP‑статусы, ошибки, число незавершённых запросов, фактическую скорость, а также latency (p50/p90/p99/max). Всё складывалось в JSON, чтобы сравнивать baseline и варианты исправлений не "на глаз", а цифрами.
Почему я смотрел heap после принудительного GC
Помимо стандартных `heapUsed` и RSS, я отдельно измерял кучу после принудительной сборки мусора. Для этого Node запускался локально с `--expose-gc`.
Здесь важна оговорка: принудительный GC - это диагностический приём, а не лечение. Если объект достижим, сборщик мусора его не удалит, сколько ни вызывай `global.gc()`. Но принудительный GC помогает отделить "временные всплески" от действительно удерживаемых объектов.
После прогона в 200 запросов часть heap стабильно оставалась занятой даже после GC. Это был прямой сигнал: нужно не "крутить лимиты", а снимать heap snapshots и смотреть цепочки удержания (retaining paths).
Что показал heap snapshot: таймер, который держал весь рендер
В одном из путей удержания обнаружилась цепочка примерно следующего смысла:
- TanStack Query ставит таймер на очистку query (garbage collection по времени)
- таймер продолжает жить и после завершения SSR‑запроса
- через `options` и `queryFn` таймер удерживает контексты рендера
- через эти контексты в памяти остаются `request`, `response`, а иногда и связанные с ними объекты вроде `socket`
Сам по себе таймер "весит" немного. Проблема в другом: callback таймера тащит за собой замыкание и ссылки, а значит - целую гроздь объектов, которые уже должны были стать мусором после отправки ответа. Пока существует такой достижимый путь, GC ничего не освободит.
Как "клиентская" библиотека оказалась причиной серверного роста памяти
Мой внутренний образ TanStack Query долгое время был сугубо браузерным: кэш в клиенте, повторные заходы на экран, рефетчи, обновления. И именно поэтому глобальная настройка с большим временем жизни кэша не выглядела подозрительно - на клиенте это действительно полезно.
Но в проекте получилось так:
- на каждый SSR‑рендер создавался новый `QueryClient`
- при этом для query применялась единая общая конфигурация с большим `gcTime` - и для браузера, и для сервера
- SSR‑клиент после ответа больше никому не нужен, но его таймеры очистки продолжают "тика́ть"
В итоге при большом потоке SSR‑запросов накапливалось много активных таймеров, и каждый из них мог удерживать тяжёлые объекты текущего запроса.
Проверка гипотезы: первая правка и ожидаемый эффект
Первое исправление оказалось концептуально простым: разделить настройки для client‑side и server‑side и перестать тянуть "долгий" `gcTime` на сервер.
На сервере кэш не нужен "на потом" - `QueryClient` живёт ровно один рендер. Поэтому разумнее задавать серверные значения так, чтобы они не создавали долгоживущие сущности и не порождали хвосты из таймеров.
После правки в локальном тесте память стала вести себя предсказуемее: heap после GC перестал ступеньками расти от прогона к прогону, а RSS перестал уверенно ползти к лимиту, который ранее неизбежно приводил к рестарту.
Почему не стоит слепо ставить `gcTime: 0`
Соблазн после такой находки - везде написать `gcTime: 0` и "закрыть тикет". Это плохая стратегия по нескольким причинам:
1. Нулевые/слишком агрессивные значения могут ударить по производительности: кэш перестанет работать даже там, где он полезен (например, в браузере).
2. Не все запросы одинаковы: где-то данные реально нужны при навигации назад/вперёд, где-то нет.
3. Резкие значения усложняют отладку: можно получить лишние рефетчи и деградации UX, которые будут всплывать позже и уже не ассоциироваться с "лечением памяти".
Правильнее: на сервере - короткая жизнь и минимум хвостов, на клиенте - значения под UX и нагрузку.
Поиск остальных "долгих" query
После того как причина стала понятна, важно было не ограничиться одной точкой. Я прошёлся по проекту и проверил:
- где создаются `QueryClient` и какие дефолты подмешиваются "по умолчанию"
- какие query тащат в `queryFn` лишний контекст (например, весь request вместо нужных пары заголовков)
- где SSR‑рендер прокидывает в замыкания объекты, которые лучше "распаковать" заранее (взять примитивы/строки, а не передавать целиком `req/res`)
Даже при нормальном `gcTime` вредно, когда `queryFn` держит тяжёлый контекст. Это не всегда приводит к утечке, но почти всегда увеличивает шансы на удержание лишнего при ошибках в жизненном цикле.
Что точно не помогло бы (или помогло бы только "косметически")
Несколько популярных "решений", которые в такой ситуации дают ложное ощущение контроля:
- Просто поднять лимит памяти в PM2 - процесс будет падать позже, но падать продолжит.
- Чаще дергать GC - если объект достижим через таймер/замыкание, GC бессилен.
- Ориентироваться только на RSS‑графики - без snapshots и retaining paths легко лечить не то.
- Пытаться "оптимизировать" нагрузочный скрипт - если не вычитывать ответы до конца, можно замаскировать проблему, а не устранить её.
Практика: как снижать риск повторения
Чтобы подобные истории не возвращались, полезно закрепить несколько подходов в проекте:
1. Явно разделять server и client конфигурацию кэша. Всё, что хорошо для браузера, не обязано быть хорошим для SSR.
2. Не хранить request/response в замыканиях. Для авторизации и локали обычно достаточно пары строк: токена, языка, идентификатора пользователя.
3. Стандартизировать создание `QueryClient` на сервере: один рендер - один клиент - предсказуемая конфигурация - минимум долгоживущих таймеров.
4. Регулярно делать локальные прогоны с ограничением памяти, особенно перед релизами, где менялись SSR‑части или кэширование.
5. Смотреть на heap после GC и делать snapshots по шагам, а не только в момент "когда всё плохо": так легче поймать рост на ранней стадии.
Итог
PM2 рестартовал Next.js‑воркеры не потому, что "PM2 капризный", а потому что процесс системно раздувался по RSS и упирался в лимит. Корень оказался в том, что TanStack Query на сервере наследовала "клиентскую" стратегию кэширования: большой `gcTime` приводил к множеству активных таймеров, а те через замыкания удерживали контексты SSR‑рендеров и связанные объекты запроса. Разделение конфигурации для клиента и сервера, а также более аккуратная работа с контекстом в `queryFn`, вернули памяти нормальное поведение и убрали циклические рестарты.


