Як налагоджувати застосунок та знаходити витоки пам'яті
Витік пам'яті (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.
Швидкий приклад
// ПОГАНО: 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 | Браузер, React | Snapshot, дії, другий snapshot, diff у Summary view | Для Node потрібен --inspect |
node --heap-prof | Node.js сервер | node --heap-prof server.js, відкрий .heapprofile у DevTools | Сповільнює процес |
| React Profiler | Повторні рендери | Запис mount/unmount, flamegraph для утриманих вузлів | Не бачить таймери і не-React код |
clinic.js / heapdump | Продакшн Node | clinic 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:
// Неправильно: interval живе до перезавантаження сторінки
useEffect(() => {
const id = setInterval(fetchData, 5000);
}, []);
// Правильно: очищення при демонтажі
useEffect(() => {
const id = setInterval(fetchData, 5000);
return () => clearInterval(id);
}, []);fetch без AbortController:
// Неправильно: запит у польоті тримає замикання зі 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 для кешу:
// Неправильно: Map тримає сильне посилання, ключі ніколи не збираються GC
const cache = new Map();
cache.set(userObject, computedData);
// Правильно: WeakMap відпускає ключ, коли інших посилань немає
const cache = new WeakMap();
cache.set(userObject, computedData);addEventListener без removeEventListener:
// Неправильно: слухач залишається назавжди
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// Правильно
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);Express обробник, який тримає великі дані на кожен запит:
// ПОГАНО: 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, що живе після демонтажу компонента.
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. Потрібно очистити і таймер, і запит.
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 розробників зненацька. Виглядає нешкідливо, доки графіки пам'яті о третій ночі не кажуть інше.
// ПОГАНО: тримає великі дані на кожен запит, назавжди
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 таймера.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.