Skip to main content

Як налагоджувати застосунок та знаходити витоки пам'яті

Витік пам'яті (memory leak) - це коли виділена пам'ять не звільняється, бо код тримає на неї посилання і garbage collector не може до неї дістатися.

Теорія

TL;DR

  • Пам'ять - як столик у ресторані: зарезервував, поїв, офіціант прибрав. Витік - це брудний посуд, що накопичується, бо ніхто не прибирає, поки зала не заповниться.
  • JS звільняє пам'ять автоматично через garbage collection. Витоки виникають, коли замикання, таймери або слухачі подій тримають об'єкти досяжними після того, як вони вже не потрібні.
  • Якщо пам'ять стабільно зростає під час навігації (видно в Task Manager або Performance Monitor), спочатку профілюй, потім роби висновки.
  • Витоки в браузері: Chrome DevTools Memory tab. У Node.js: --heap-prof або clinic.js. У React: React Profiler разом з heap snapshots.

Швидкий приклад

tsx
// ПОГАНО: interval продовжує працювати після демонтажу компонента function LeakyTimer() { useEffect(() => { const id = setInterval(() => console.log('tick'), 1000); // Немає return = немає очищення = interval живе вічно // Замикання тримає scope компонента, heap росте }, []); return <div>Timer</div>; } // ДОБРЕ: повертаємо функцію очищення function CleanTimer() { useEffect(() => { const id = setInterval(() => console.log('tick'), 1000); return () => clearInterval(id); // тепер GC може зібрати }, []); return <div>Timer</div>; }

Після демонтажу LeakyTimer interval продовжує спрацьовувати. Замикання тримає scope компонента, тому V8 вважає ту пам'ять досяжною і не чіпає її. Виправлення - один рядок.

Чому GC тебе не врятує

V8 використовує mark-and-sweep: починає від коренів (глобальні об'єкти, стек-фрейми, активні хендли), помічає все досяжне, а решту видаляє. Витоки виникають, коли код випадково створює кореневі посилання, що живуть довше ніж треба.

setInterval кладе свій callback у чергу таймерів. Ця черга є коренем. Якщо callback закриває 10 МБ масиву, той масив досяжний стільки, скільки живе interval. GC його не чіпає. Стекові змінні різняться: вони зникають щойно функція завершується. Але замикання, слухачі подій і глобальний стан сидять на heap і живуть доти, доки щось явно не розриває посилання.

Коли який інструмент

  • Пам'ять зростає під час навігації в браузері → Chrome DevTools Memory tab, heap snapshots до і після дій користувача
  • Node.js сервер повільно вичерпує пам'ять → node --heap-prof server.js, потім відкрий результат у DevTools
  • React компонент спричиняє повторне зростання пам'яті → React Profiler разом з DevTools Memory tab
  • Продакшн Node з OOM → модуль heapdump, тригер через SIGUSR2
  • Ловити витоки в CI → Puppeteer з page.tracing і порівнянням heap між запусками

Порівняння інструментів

ІнструментДе допомагаєЯк використатиОбмеження
Chrome DevTools MemoryБраузер, ReactSnapshot, дії, другий snapshot, diff у Summary viewДля Node потрібен --inspect
node --heap-profNode.js серверnode --heap-prof server.js, відкрий .heapprofile у DevToolsСповільнює процес
React ProfilerПовторні рендериЗапис mount/unmount, flamegraph для утриманих вузлівНе бачить таймери і не-React код
clinic.js / heapdumpПродакшн Nodeclinic heapusage -- node server.jsПотрібна установка
Performance MonitorШвидка перевіркаDevTools → Performance Monitor → JS Heap SizeТільки підтверджує факт витоку, без деталей

Як V8 обробляє це всередині

V8 запускає два типи GC. Scavenge (minor GC) швидко очищає молоде покоління, збираючи короткоживучі об'єкти. Mark-Compact (major GC) обробляє старе покоління, але запускається тільки під тиском пам'яті. Об'єкти, що пережили Scavenge, переходять до старого покоління.

Витоки живуть у старому поколінні. Callback таймера, що закриває великий об'єкт, тримає той об'єкт там. Кожного разу коли Mark-Compact запускається, він знаходить об'єкт досяжним і пропускає його. Після тисяч запитів або навігаційних подій ці об'єкти накопичуються до OOM або перезавантаження.

Відокремлені DOM вузли - поширений браузерний варіант. Ти видаляєш елемент з DOM, але JS змінна або слухач події ще тримає посилання. Елемент відокремлений від сторінки, але живий на heap. Chrome DevTools показує такі об'єкти в Summary view як "Detached HTMLDivElement".

Типові помилки

Забув очистити setInterval у useEffect:

tsx
// Неправильно: interval живе до перезавантаження сторінки useEffect(() => { const id = setInterval(fetchData, 5000); }, []); // Правильно: очищення при демонтажі useEffect(() => { const id = setInterval(fetchData, 5000); return () => clearInterval(id); }, []);

fetch без AbortController:

tsx
// Неправильно: запит у польоті тримає замикання зі setData useEffect(() => { fetch('/api/data').then(r => r.json()).then(setData); }, []); // Правильно: AbortController скасовує запит при демонтажі useEffect(() => { const controller = new AbortController(); fetch('/api/data', { signal: controller.signal }) .then(r => r.json()) .then(setData) .catch(() => {}); // AbortError очікуваний, ігноруємо return () => controller.abort(); }, []);

Map замість WeakMap для кешу:

js
// Неправильно: Map тримає сильне посилання, ключі ніколи не збираються GC const cache = new Map(); cache.set(userObject, computedData); // Правильно: WeakMap відпускає ключ, коли інших посилань немає const cache = new WeakMap(); cache.set(userObject, computedData);

addEventListener без removeEventListener:

tsx
// Неправильно: слухач залишається назавжди useEffect(() => { window.addEventListener('resize', handleResize); }, []); // Правильно useEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []);

Express обробник, який тримає великі дані на кожен запит:

js
// ПОГАНО: interval закриває 10 МБ буфер, витік на кожен запит app.get('/data', async (req, res) => { const largeData = await fetchBigPayload(req.query.id); const watcher = setInterval(() => { console.log('Розмір:', largeData.length); // тримає largeData }, 5000); res.json({ ok: true }); // interval не очищено, largeData ніколи не звільниться });

Такий патерн я бачив на Node сервері з 500 одночасних користувачів: кожен запит додавав ~10 МБ до heap і пам'ять назад не поверталася.

Ігноруєш detached DOM вузли у heap snapshot:

Коли в snapshot бачиш "Detached HTMLDivElement", це DOM вузол, видалений зі сторінки, але ще живий у JS. Перевір слухачі подій, які не були видалені, та React ref об'єкти, що вказують на старі вузли.

Де зустрічається в продакшні

  • React компоненти з WebSocket (чати, live дашборди) → закривати socket у return useEffect
  • Next.js SSR сторінки з підписками → cleanup у getServerSideProps
  • Puppeteer сервіси скріншотів → регулярний page.close() для відокремлених сторінок
  • Redux stores → не зберігати DOM вузли або великі ArrayBuffer у стані
  • Node/Express API → factory-функції на кожен запит замість синглтонів на рівні модуля

Питання на співбесіді

Q: Розкажи, як ти будеш дебажити React застосунок, що сповільнюється через 10 хвилин навігації.
A: Спочатку Task Manager або Performance Monitor - підтвердити, що пам'ять справді зростає. Потім DevTools Memory tab, перший heap snapshot, кілька хвилин навігації, другий snapshot, diff у Summary view. Шукаю зростаючі замикання або detached DOM вузли. Далі Performance tab із записом сесії для виявлення interval-колбеків у flame chart.

Q: Яка різниця між heap snapshot і allocation timeline?
A: Snapshot - це фото всіх об'єктів у heap у конкретний момент. Timeline записує кожну алокацію впродовж часу, що допомагає ловити переривчасті витоки, які не видно при порівнянні двох snapshot.

Q: Як дебажити витік пам'яті в Node.js без перезапуску сервера?
A: Запустити Node з --inspect і підключитися через Chrome DevTools. Або встановити heapdump, слухати SIGUSR2 і викликати heapdump.writeSnapshot() за сигналом. Потім аналізувати .heapsnapshot файл у DevTools.

Q: Чому WeakMap допомагає запобігти витокам?
A: У звичайному Map ключ - сильне посилання. Якщо кешуєш дані за об'єктом користувача, той об'єкт живе доти, доки живе Map. У WeakMap ключ тримається слабо: щойно інший код перестає посилатися на об'єкт, GC збирає його і запис у WeakMap зникає автоматично.

Q: React StrictMode монтує компоненти двічі. Як це допомагає знайти витоки?
A: StrictMode робить mount, cleanup і знову mount. Якщо useEffect створює таймер або слухач, але cleanup їх не видаляє, після другого mount їх буде два. Heap snapshot після другого mount покаже подвоєні алокації.

Q: Чому витоки в старому поколінні V8 небезпечніші?
A: Молоде покоління очищає Scavenge, який запускається часто і швидко. Об'єкти, що пережили Scavenge, переходять до старого покоління, де їх збирає тільки повний Mark-Compact GC. Об'єкти-витоки залишаються там назавжди, поступово заповнюючи heap до OOM.

Приклади

Базовий: витік setInterval у React компоненті

Найпоширеніший варіант: polling interval, що живе після демонтажу компонента.

tsx
function PriceTracker({ symbol }: { symbol: string }) { const [price, setPrice] = useState<number>(0); useEffect(() => { // Оновлюємо ціну кожні 3 секунди const id = setInterval(async () => { const res = await fetch(`/api/price?symbol=${symbol}`); const data = await res.json(); setPrice(data.price); // попередження після демонтажу: оновлення мертвого компонента }, 3000); return () => clearInterval(id); // один рядок запобігає витоку }, [symbol]); return <div>{symbol}: ${price}</div>; }

Без return id продовжує працювати після демонтажу PriceTracker. Замикання захоплює symbol і setPrice, тому scope компонента ніколи не збирається. З очищенням GC бачить відсутність посилань і звільняє пам'ять.

Середній: пошук з debounce і abort

Реалістичний сценарій для адмін-панелі: користувач вводить у поле пошуку, ти робиш debounce і fetch. Потрібно очистити і таймер, і запит.

tsx
function UserSearch({ users }: { users: User[] }) { const [results, setResults] = useState<User[]>([]); useEffect(() => { const controller = new AbortController(); // Чекаємо 300 мс перед відправкою запиту const timer = setTimeout(async () => { try { const res = await fetch('/api/users/search', { signal: controller.signal, }); const data = await res.json(); setResults(data); } catch { // AbortError очікуваний при очищенні } }, 300); return () => { clearTimeout(timer); // скасувати очікуваний debounce controller.abort(); // скасувати запит у польоті }; }, [users]); return <ul>{results.map(u => <li key={u.id}>{u.name}</li>)}</ul>; }

Без очищення кожне натискання клавіші планує таймер, що захоплює весь масив users. Швидкий набір тексту = багато замикань = heap постійно росте.

Просунутий: Express обробник з таймером, що витікає на кожен запит

Цей варіант ловить senior розробників зненацька. Виглядає нешкідливо, доки графіки пам'яті о третій ночі не кажуть інше.

js
// ПОГАНО: тримає великі дані на кожен запит, назавжди app.get('/data', async (req, res) => { const largeData = await fetchBigPayload(req.query.id); // ~10 МБ буфер const watcher = setInterval(() => { // Замикання захоплює largeData. Таймер ніколи не очищається. console.log('Розмір payload:', largeData.length); }, 5000); res.json({ ok: true }); // Відповідь відправлена, але largeData живе у замиканні таймера // 100 запитів = ~1 ГБ heap }); // ДОБРЕ: очищаємо interval після завершення відповіді app.get('/data', async (req, res) => { const largeData = await fetchBigPayload(req.query.id); let watcher: ReturnType<typeof setInterval>; res.on('finish', () => clearInterval(watcher)); // спрацьовує після відповіді watcher = setInterval(() => { console.log('Розмір payload:', largeData.length); }, 5000); res.json({ ok: true }); });

Щоб зловити це в продакшні: node --heap-prof server.js, запусти навантажувальний тест, відкрий .heapprofile у Chrome DevTools. Шукай fetchBigPayload у retainer chain і слідуй до callback таймера.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?