Memory leaks

Утечки памяти в TanStack Query почти никогда не связаны с «сырой» утечкой JavaScript-объектов в классическом смысле. Основная проблема заключается в долгоживущем кеше QueryClient, накоплении QueryObserver, а также в некорректном управлении временем жизни запросов и подписок на данные.

В основе системы лежит реестр запросов (query cache), где каждый запрос идентифицируется queryKey. Каждый активный useQuery создаёт наблюдателя (observer), который подписывается на изменения кеша. Пока существует хотя бы одна подписка или запрос не считается «собранным мусором», данные остаются в памяти.


Жизненный цикл запроса и точки удержания памяти

Каждый query проходит несколько состояний:

  • создание и регистрация в кеше
  • подписка одного или нескольких observers
  • переход в inactive состояние (нет подписчиков)
  • ожидание GC (garbage collection) по таймеру gcTime

Ключевая проблема возникает на этапе перехода в inactive состояние. Даже если компонент размонтирован, query может оставаться в кеше, если:

  • gcTime (старое cacheTime) слишком велик
  • запрос постоянно пересоздаётся с новыми ключами
  • подписки не удаляются из-за внешних ссылок
  • есть фоновые refetch механизмы

gcTime и удержание данных в памяти

Параметр gcTime определяет, сколько query остаётся в памяти после потери последнего подписчика.

Если значение завышено или оставлено по умолчанию в приложении с большим количеством динамических запросов, происходит постепенное накопление объектов:

  • query state
  • results
  • error objects
  • meta-информация
  • observers, которые ещё не были очищены

Особенно заметно это в SPA с навигацией между страницами, где каждый переход создаёт новые ключи.

Проблема усиливается при использовании динамических ключей вида:

useQuery({
  queryKey: ['items', filters],
  queryFn: fetchItems
})

Если filters создаются как новый объект каждый рендер, cache начинает раздуваться бесконечно.


Утечки через нестабильные queryKey

Одной из самых частых причин утечек является нестабильность ключей.

Пример проблемного поведения:

const filters = { status: 'active' }

useQuery({
  queryKey: ['users', filters],
  queryFn: fetchUsers
})

Если filters пересоздаётся на каждом рендере, даже с одинаковыми значениями, создаются разные ссылки. TanStack Query воспринимает это как новый запрос.

Результат:

  • рост количества записей в кеше
  • невозможность переиспользовать данные
  • увеличение потребления памяти

Решение заключается в стабилизации ключей через примитивы или мемоизацию структуры.


Observer leaks и долгоживущие подписки

Каждый useQuery создаёт QueryObserver, который подписывается на QueryCache.

Если компонент не корректно размонтируется или остаются внешние ссылки на observer, происходит удержание памяти.

Типичные сценарии:

  • подписки через queryClient.getQueryCache().subscribe
  • кастомные обёртки над useQuery
  • сохранение observer в singleton-сервисах
  • неправильная интеграция с event-emitter архитектурой

Даже один «зависший» observer удерживает весь query graph, включая данные и метаинформацию.


Strict Mode и двойные запросы

React Strict Mode в development режиме может вызывать двойное монтирование компонентов. Это приводит к:

  • двойной регистрации query
  • двойным подпискам
  • временным «висящим» экземплярам observer

Хотя TanStack Query умеет корректно очищать большинство таких случаев, утечки возникают при:

  • side-effect внутри queryFn
  • ручных подписках на cache события
  • внешнем сохранении состояния query

Утечки при setQueryData и внешних ссылках

setQueryData позволяет вручную записывать данные в кеш. Проблема возникает, когда в кеш сохраняются ссылки на большие структуры:

  • DOM-объекты (ошибка архитектуры)
  • крупные JSON с вложенными ссылками
  • данные, содержащие замыкания
queryClient.setQueryData(['data'], (old) => {
  return {
    ...old,
    hugeField: window.someBigObject
  }
})

Если такие данные попадают в кеш, они удерживаются до удаления query, а иногда дольше — если на них есть внешние ссылки.


Поллинг и фоновые refetch как источник накопления

Постоянные refetchInterval создают непрерывную активность:

  • query никогда не становится idle
  • GC не срабатывает
  • observer остаётся активным

Это особенно критично для:

  • dashboard-дашбордов
  • realtime-таблиц
  • мониторинговых систем
useQuery({
  queryKey: ['metrics'],
  queryFn: fetchMetrics,
  refetchInterval: 5000
})

Если таких запросов десятки или сотни, память стабильно растёт.


focus/refetch listeners и глобальные обработчики

TanStack Query использует глобальные listeners:

  • visibilitychange
  • focus
  • reconnect events

При неправильной инициализации QueryClient в нескольких экземплярах приложения могут возникнуть:

  • дублирующиеся listeners
  • утечки замыканий
  • повторное добавление обработчиков без удаления

Типичный анти-паттерн:

  • создание new QueryClient() внутри компонента
  • пересоздание клиента при каждом рендере root-level компонента

Infinite Queries и рост внутреннего массива страниц

useInfiniteQuery хранит массив страниц (pages), который может расти бесконтрольно:

  • при отсутствии ограничения getNextPageParam
  • при бесконечном scroll
  • при неправильной invalidation логике
useInfiniteQuery({
  queryKey: ['feed'],
  queryFn: fetchFeed,
  getNextPageParam: (lastPage) => lastPage.nextCursor
})

Если cursor всегда возвращает новое значение без ограничения, pages продолжают расти, увеличивая память линейно.


Hydration SSR и повторное восстановление кеша

При SSR гидратации возможны утечки, если:

  • dehydrated state не очищается
  • повторная гидратация происходит без reset
  • кеш клиента пересоздаётся поверх старого состояния

Особенно критично при:

  • Next.js app router
  • повторной навигации между server/client boundary

Старые данные могут оставаться в памяти даже после смены маршрута.


Devtools как скрытый источник удержания памяти

React Query Devtools создают постоянные подписки на cache:

  • наблюдение за изменениями query
  • хранение ссылок на все query state
  • обновление UI панели

В production это обычно отключено, но в development может создавать значительное потребление памяти при большом количестве запросов.


Утечки через неправильную инвалидацию

Некорректная стратегия invalidation может приводить к:

  • постоянному пересозданию query
  • накоплению старых версий данных
  • росту числа активных observers

Пример:

queryClient.invalidateQueries({ queryKey: ['items'] })

При частом вызове без debounce это приводит к лавинообразному росту активности кеша.


Накопление ошибок и retry-циклы

Retry механизм также влияет на память:

  • хранит историю попыток
  • удерживает error объекты
  • создаёт временные closures для повторов

При retry: Infinity или высокой частоте retry возможен рост памяти в нестабильных сетевых условиях.


Практические причины деградации памяти

Наиболее частые источники в реальных приложениях:

  • динамические queryKey без стабилизации
  • бесконтрольный infinite scroll
  • постоянный polling без unmount стратегии
  • глобальные singleton-ошибки QueryClient
  • сторонние подписки на cache без cleanup
  • SSR hydration без очистки состояния

Архитектурные особенности, усиливающие проблему

Некоторые архитектуры усиливают утечки:

  • микрофронтенды с общим QueryClient
  • SPA с долгими сессиями без перезагрузки
  • dashboards с десятками live-запросов
  • приложения с websocket + query sync без throttling

В таких системах даже небольшие утечки на уровне observer становятся накопительными и приводят к росту потребления памяти со временем.