В проекте периодически перезапускались PM2-воркеры. Сами рестарты сначала и были проблемой: процесс завершался, PM2 запускал новый, какое-то время всё работало нормально, затем ситуация повторялась.
По логам было понятно, что воркеры перезапускаются после достижения установленного лимита памяти. Увеличили лимит, воркеры стали жить дольше, но перезапуски никуда не делись: процесс просто позже доходил до нового значения.
И я пошел искать причину.
В статье расскажу:
как воспроизводил нагрузку;
как искал удерживаемые объекты в heap snapshot;
при чём здесь
gcTimeTanStack Query.
От рестартов к памяти
Стек приложения:
Node.js 20;
Next.js 16 с App Router;
TanStack Query;
SSR с отдельным
QueryClientдля каждого server render.
PM2 следил за памятью каждого Node.js-процесса. Из логов было видно, что перед рестартом RSS воркера доходил до заданного лимита.
RSS — это вся физическая память процесса, а не только JavaScript heap. Сюда входит память V8, Buffer, нативные библиотеки, стеки и другие области. Поэтому по одному графику RSS нельзя понять, какие именно объекты остаются в памяти.
Так я перешёл от исходного симптома — рестартов PM2 — к следующему вопросу: почему память процесса растёт и какие объекты остаются в ней после обработки запросов. Для ответа нужен был локальный эксперимент.
Воспроизводим нагрузку
Я собрал Docker-окружение с ограничениями:
1280 MiB памяти;
2 CPU;
одинаковый образ приложения;
одинаковый маршрут и профиль запросов;
200 запросов на один проход.
Нагрузку я генерировал скриптом с такими параметрами:
200 запросов;
concurrency — 2;
целевая скорость — 10 RPS;
timeout одного запроса — 30 секунд;
фактическая скорость — примерно 9,8–9,93 RPS.
Основная часть генератора:
function sendOne() { if (sent >= total || active >= concurrency) return sent++ active++ const startedAt = performance.now() const req = http.get( target, { agent: new http.Agent({ keepAlive: true, maxSockets: concurrency, }), }, (res) => { statuses[res.statusCode] = (statuses[res.statusCode] || 0) + 1 res.resume() res.on('end', () => done(res.statusCode >= 400, startedAt)) }, ) req.setTimeout(30_000, () => { req.destroy(new Error('timeout')) }) req.on('error', () => done(true, startedAt)) } setInterval(sendOne, 1000 / targetRps)
res.resume() здесь важен: скрипт полностью вычитывал тело ответа, а не считал запрос завершённым сразу после получения заголовков.
Для каждого прохода собирались HTTP-статусы, ошибки, число незавершённых запросов, фактическая скорость и latency p50, p90, p99 и max. Результат записывался в JSON, чтобы потом можно было сравнить baseline и вариант с исправлением.
Кроме обычных heapUsed и RSS, я смотрел heap после принудительной сборки мусора (GC). Для этого локально запускали Node.js с --expose-gc.
function getHeapAfterGc() { if (typeof global.gc !== 'function') { throw new Error('Run Node.js with --expose-gc') } global.gc() return process.memoryUsage().heapUsed }
Принудительный GC нужен был только для диагностики. Добавлять его в production как способ борьбы с ростом памяти смысла нет: если объект всё ещё достижим, сборщик мусора его не удалит.
После 200 запросов часть heap стабильно оставалась занята даже после GC. Значит, нужно было снимать heap snapshots и смотреть retaining paths.
Что нашли в heap snapshot
В одном из retaining paths была примерно такая цепочка:
TimersList → Timeout → query options / queryFn → React/Next render contexts → ServerResponse / NodeNextRequest / Socket
Получалось следующее:
TanStack Query создавала таймер для очистки query.
Таймер оставался активным после завершения SSR-запроса.
Через options и
queryFnон удерживал контексты рендера.Через них в памяти оставались request, response и socket.
Сам таймер занимает немного памяти. Но его callback через замыкание и другие ссылки сохранял доступ к объектам query, контекстам рендера и объектам запроса. Пока существует такая цепочка, сборщик мусора не может освободить эти объекты.
На каждый server render в приложении создавался отдельный QueryClient. При этом для query использовалась одна общая настройка большого gcTime — и в браузере, и на сервере.
Упрощённо настройка выглядела так:
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 5 * 60 * 1000, }, }, })
Разделения на server-side и client-side настройки не было. На клиенте большое значение gcTime полезно: пользователь может вернуться на экран и получить данные из кэша. Поэтому сама настройка не выглядела подозрительно.
Но та же конфигурация применялась и на сервере. QueryClient создавался для одного рендера. Ответ уже отправлен, повторно этот экземпляр никто не использует, но таймер очистки продолжает работать.
При большом количестве SSR-запросов таких таймеров становилось много.
Как клиентская библиотека оказалась в Node.js
Для меня это было неочевидно. Я воспринимал TanStack Query в первую очередь как клиентскую библиотеку: запросы из React-компонентов, кэш в браузере, повторная загрузка и обновление данных.
При SSR часть React-приложения выполняется внутри Node.js ещё до того, как страница попадёт в браузер. На сервере создаётся QueryClient, выполняется prefetch, формируется dehydrated state и проходит первоначальный рендер.
То есть настройка TanStack Query реально выполняется внутри Node.js. Если серверному QueryClient передать общий конечный gcTime, библиотека создаст таймеры уже в event loop Node.js.
Именно этого я изначально не учитывал. Мне казалось, что настройка управляет клиентским кэшем. На практике она влияла и на время жизни объектов во время SSR.
Что говорит документация TanStack Query
Настало время прочитать, наконец, инструкцию.
В разделе High memory consumption on server описан похожий сценарий: если создавать отдельный QueryClient для каждого запроса, его изолированный кэш сохраняется в памяти на период gcTime. При большом количестве запросов это может привести к высокому потреблению памяти.
Если явно задан конечный gcTime, документация предлагает:
очищать
QueryClient, когда данные больше не нужны;либо использовать меньшее значение
gcTime.
То есть проблема была не в каком-то необычном поведении Node.js. В какой-то момент мы просто без отдельного решения перенесли клиентское время жизни кэша в серверный сценарий, где сам QueryClient жил намного меньше.
Проверяем первую правку
Найти подозрительный retaining path недостаточно. Нужно проверить, что изменение действительно влияет на память.
Я сделал matched A/B:
тот же Docker image;
те же лимиты памяти и CPU;
тот же прогрев;
тот же маршрут;
те же 200 запросов;
client-side
gcTimeне меняли;изменил только server-side
gcTime.
В baseline оставил прежнее общее большое значение. В тестовом варианте разделил настройки и установил server-side gcTime: 0, сохранив прежнее значение в браузере.
Результат после GC:
Вариант | Рост heap после 200 запросов |
|---|---|
Большой server-side | 45,22 MiB |
Server-side | 4,68 MiB |
Получилось предотвратить 89,65% роста heap относительно baseline:
(45,22 − 4,68) / 45,22 × 100% = 89,65%
Первая гипотеза подтвердилась. Долгий server-side gcTime объяснял основную часть роста памяти в локальном эксперименте.
При этом 4,68 MiB не обязательно означают ещё одну утечку. Это может быть прогрев, внутренние кэши framework или другое состояние, которое со временем стабилизируется.
Почему не стоит просто копировать gcTime: 0
В моём A/B ноль был удобен, чтобы проверить влияние таймеров. Но это не универсальная настройка для любого SSR-приложения.
Документация TanStack Query предупреждает, что gcTime: 0 может привести к hydration error: данные успеют удалиться до того, как приложение обратится к ним при рендере. Если нужен короткий период, в документации рекомендуют оставить хотя бы 2 * 1000 мс.
Другой вариант — вызвать queryClient.clear() после того, как данные больше не нужны и dehydrated state уже отправлен клиенту.
const queryClient = createServerQueryClient() try { return await renderAndSendResponse(queryClient) } finally { queryClient.clear() }
Код упрощён. В реальном приложении точка очистки зависит от streaming, hydration boundaries и способа рендера. Если очистить кэш слишком рано, можно получить уже другую проблему.
Основная идея: server-side и client-side gcTime не обязаны быть одинаковыми. Их нужно настраивать под разный жизненный цикл.
Ищем остальные query
После первой правки я проверил другие server-side query:
где явно задан
gcTime;какие
Timeoutостаются после ответа;что удерживают их
queryFnи options;ведут ли retaining paths к контекстам SSR-запроса.
Я нашёл ещё одну query с похожим таймером и внёс изменения в остальные места.
Тут был интересный момент. Если удалить таймеры только второй query, heap почти не уменьшался. Сначала выглядело так, будто она ни при чём.
Оказалось, что оба таймера в итоге ссылались на одни и те же объекты завершённого SSR-запроса:
Timer A ─┐ ├→ render context → request → socket Timer B ─┘
Допустим, объект запроса доступен по двум цепочкам ссылок — через Timer A и через Timer B. Если удалить Timer B, цепочка через Timer A остаётся. Для сборщика мусора ничего принципиально не изменилось: объект запроса всё ещё доступен, поэтому освобождать его нельзя.
Только после удаления обеих цепочек общий набор объектов становится недостижимым и может быть собран. Поэтому объём удерживаемой памяти разных таймеров нельзя просто складывать: часть объектов у них может быть общей.
После изменений я повторил нагрузку ещё раз. Рост post-GC heap значительно уменьшился. После релиза перезапуски PM2-воркеров по лимиту памяти прекратились.
Что не помогло бы
Несколько решений, которые могут временно скрыть проблему:
Увеличить лимит памяти. Воркер будет перезапускаться реже, но причина роста останется.
Регулярно запускать GC. Достижимые через таймер объекты всё равно не освободятся.
Положиться на PM2 restart. Память очистится вместе с процессом, затем начнёт расти снова.
Исправить только одну query. Другой timer root может удерживать тот же объектный граф.
Уменьшить query payload. Памяти станет меньше, но retaining path останется.
Резюме
В моём случае проблема сложилась из нескольких условий:
отдельный
QueryClientсоздавался для каждого SSR request;общая настройка задавала большой
gcTimeи в браузере, и на сервере;для очистки создавались долгие таймеры;
через
queryFnи контексты рендера таймеры удерживали завершённые request graph.
Что помогло:
я воспроизвёл нагрузку локально;
сравнил память после GC;
нашёл retaining path от
TimersListдо объектов запроса;изменил только server-side
gcTimeи повторил тест;проверил остальные query с таким же паттерном;
отдельно сохранил клиентскую политику кэширования.
Главный вывод: настройки кэша на клиенте и сервере решают разные задачи. Если QueryClient создаётся на один SSR-запрос, стоит отдельно проверить, сколько живут его query, какие таймеры они создают и что остаётся доступным через эти таймеры после завершения ответа.

