Утечки памяти — как найти и исправить

Утечка памяти в браузере — это объект, который больше не нужен приложению, но не может быть удалён сборщиком мусора, потому что на него сохраняется ссылка. Накапливаясь, утечки приводят к замедлению работы и краху вкладки.

Зачем нужно

Утечки памяти — частая причина деградации SPA: страница работает быстро при первом посещении, но замедляется через 10-30 минут взаимодействия. Garbage Collector не помогает, если на объект удерживается ссылка. Без знания паттернов утечек диагностика занимает часы.

Где используется

  • SPA-приложения (React, Vue, Angular) с длительной сессией
  • Дашборды с WebSocket и polling — постоянные обновления данных
  • Карты, графики (WebGL контекст, Canvas) — нативные ресурсы
  • Компоненты с таймерами, subscriptions, ResizeObserver

Основной контент

Основные паттерны утечек

// 1. Забытые event listeners
function leaky() {
  const bigData = new Array(10_000).fill('data');
  window.addEventListener('resize', () => {
    console.log(bigData.length); // bigData не освобождается!
  });
}

// Исправление: сохранить ссылку и удалить
function fixed() {
  const bigData = new Array(10_000).fill('data');
  const handler = () => console.log(bigData.length);
  window.addEventListener('resize', handler);
  return  => window.removeEventListener('resize', handler); // cleanup
}
// 2. Отсоединённые DOM-узлы (Detached DOM)
let detachedDiv;
function createNode() {
  detachedDiv = document.createElement('div');
  document.body.appendChild(detachedDiv);
}
function removeNode() {
  detachedDiv.remove(); // Удалён из DOM, но переменная держит ссылку
  // detachedDiv = null; // Нужно обнулить!
}

// 3. Таймеры без очистки
function startPolling() {
  const id = setInterval( => fetchData, 5000);
  // return  => clearInterval(id); // Забыли!
}
// 4. Closure-утечка — замыкание держит весь контекст
function outer() {
  const bigArray = new Uint8Array(1_000_000);
  return function inner() {
    // Достаточно одной ссылки в closure — весь bigArray живёт
    return bigArray[0];
  };
}

// Исправление: использовать только нужное значение
function outerFixed() {
  const bigArray = new Uint8Array(1_000_000);
  const firstByte = bigArray[0]; // Копируем нужное
  // bigArray теперь может быть собрана GC
  return  => firstByte;
}

WeakMap и WeakRef — слабые ссылки

// WeakMap: ключ — объект, не мешает GC
const cache = new WeakMap();

function processElement(el) {
  if (cache.has(el)) return cache.get(el);
  const result = expensiveCompute(el);
  cache.set(el, result); // Когда el удалён из DOM — запись уйдёт сама
  return result;
}

// WeakRef: ссылка не удерживает объект
class EventBus {
  #listeners = new Set();

  subscribe(callback) {
    const ref = new WeakRef(callback);
    this.#listeners.add(ref);
    return  => this.#listeners.delete(ref);
  }

  emit(data) {
    for (const ref of this.#listeners) {
      const fn = ref.deref; // null если GC собрал
      if (fn) fn(data);
      else this.#listeners.delete(ref);
    }
  }
}

React: очистка useEffect

// Классические утечки в React
function BadComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // Проблема 1: async без AbortController
    fetch('/api/data').then(r => r.json()).then(setData);

    // Проблема 2: таймер без clearInterval
    const id = setInterval(fetchUpdates, 5000);

    // Проблема 3: subscription без unsubscribe
    const sub = store.subscribe(setData);
  }, );
}

// Исправление
function GoodComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch('/api/data', { signal: controller.signal })
      .then(r => r.json())
      .then(setData)
      .catch(() => {}); // AbortError игнорируем

    const id = setInterval(fetchUpdates, 5000);
    const sub = store.subscribe(setData);

    return  => {
      controller.abort();
      clearInterval(id);
      sub.unsubscribe;
    };
  }, );
}

Диагностика в Chrome DevTools

# Шаг 1: Открыть DevTools → Memory tab
# Heap Snapshot — снимок кучи в момент времени

# Шаг 2: Сравнение снимков (выявление утечки)
# 1. Snapshot 1 (baseline)
# 2. Совершить действие N раз (открыть/закрыть компонент)
# 3. Snapshot 2
# Фильтр: Objects allocated between Snapshot 1 and 2
# Ищем: Detached HTMLElement, растущие массивы, closure

# Шаг 3: Allocation Timeline
# Выбрать "Allocation instrumentation on timeline"
# Записать сессию → синие полосы = живые объекты
// performance.memory API (только Chrome)
function checkMemory() {
  if (performance.memory) {
    const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1) + 'MB';
    console.log({
      used: mb(performance.memory.usedJSHeapSize),
      total: mb(performance.memory.totalJSHeapSize),
      limit: mb(performance.memory.jsHeapLimitSize),
    });
  }
}

// Мониторинг роста памяти
let baseline;
function trackMemoryGrowth() {
  if (!performance.memory) return;
  if (!baseline) {
    baseline = performance.memory.usedJSHeapSize;
    return;
  }
  const growth = performance.memory.usedJSHeapSize - baseline;
  if (growth > 50 * 1024 * 1024) { // >50MB рост
    console.warn('Possible memory leak detected', growth);
  }
}
setInterval(trackMemoryGrowth, 30_000);

Частые ошибки

  • Удаление DOM-узла без обнуления переменной — Detached DOM продолжает жить
  • addEventListener без парного removeEventListener в компонентах с unmount
  • setInterval / setTimeout без clearInterval / clearTimeout в cleanup
  • Глобальные кеши (Map, массивы) без LRU-ограничения — бесконечный рост
  • Игнорирование WeakMap/WeakRef там, где ключ — DOM-элемент или объект

Связанные темы

🎓 Источники

Ресурсы