JavaScript Memory Management & Leak Hunting — V8 GC, DevTools, and Production Signals
How V8 garbage collection works, why leaks are reachability bugs, classic frontend leak patterns, WeakRef tradeoffs, and a DevTools hunting workflow.
Tab chạy ổn mười phút, rồi quạt kêu và scroll giật. Heap tăng theo dạng răng cưa nhưng không hồi baseline. Hiếm khi là “JavaScript chậm” — thường là reachability ngoài ý muốn giữ object sống sau khi mental model của bạn nghĩ chúng đã biến mất.
Bài này dành cho engineer đã biết closure và event loop. Chúng ta đi sâu hơn: memory model V8, vì sao ngôn ngữ GC vẫn leak, cách săn leak bằng Chrome DevTools, và đo lường production mà không tự lừa mình.
Mô hình bộ nhớ — stack, heap, và value graph
Giá trị JavaScript nằm ở hai nơi:
| Location | Holds | Lifetime |
|---|---|---|
| Stack (per call frame) | Primitives (number, boolean, null, undefined, bigint, symbol), references (pointers to heap objects) | Popped when the function returns |
| Heap | Objects, arrays, closures, typed arrays, Map/Set, functions | Managed by the garbage collector |
Primitive được copy theo giá trị. Object được copy theo reference — gán b = a không clone object; cả hai biến trỏ cùng một heap node.
Runtime xây đồ thị có hướng gồm object liên kết qua property, phần tử mảng, closure scope, và internal edge (vd prototype). GC không hỏi “object này còn hữu ích với lập trình viên không?”. Nó hỏi: object này có reachable từ root không?
Root — nơi reachability bắt đầu
Root là điểm vào engine phải coi là luôn sống trong một chu kỳ collection:
- Call stack hiện tại và biến local
- Global object (
globalThis) và module scope - Handle đăng ký với embedder (vd DOM wrapper trong browser)
- Timer đang chạy, microtask, và một số bảng nội bộ VM
Nếu có đường từ bất kỳ root nào tới object X, X reachable và sẽ không bị thu gom. Không có đường nghĩa là unreachable — đủ điều kiện thu hồi, dù bạn vẫn có tên biến trông như còn giữ giá trị.
[Root: global] ──► moduleCache ──► { id: 1, payload: Array(1_000_000) }
▲
[Root: timer callback] ─────┘
(forgotten clearInterval — entire subgraph stays alive)
Nhận thức cấp principal: Bộ nhớ không “được giải phóng” khi gán
nulltrừ khi điều đó cắt đường retain cuối cùng.obj = nullchỉ xóa một cạnh; reference khác vẫn có thể neo subgraph.
Cách garbage collector V8 hoạt động
V8 (Chrome, Node.js, Electron) dùng collector generational tối ưu cho allocation sống ngắn — pattern chi phối trong app JS.
Young generation — Scavenge (copying GC)
Object mới allocate trong new space (thường gọi nursery).
- Bắt đầu từ root, đánh dấu object reachable trong young generation
- Copy survivor sang to-space, compact chúng
- Bỏ toàn bộ from-space một lần — không duyệt free list từng object
Scavenge nhanh vì young generation nhỏ và hầu hết object chết sớm (generational hypothesis). Object sống qua đủ chu kỳ Scavenge được promote lên old generation.
Old generation — Mark-Sweep-Compact
Object sống lâu nằm trong old space.
| Phase | What happens |
|---|---|
| Mark | Trace from roots; mark reachable old-space objects |
| Sweep | Reclaim unmarked objects; may leave fragmentation |
| Compact | Move live objects to reduce fragmentation (not every cycle) |
Full collection old-gen tốn kém trên heap lớn. V8 vì vậy dùng incremental marking (chia nhỏ công việc qua các slice idle/main-thread) và concurrent marking (thread phụ mark trong khi JS chạy) để giảm pause.
Bạn vẫn thấy GC pause trong DevTools — nhất là khi promotion rate cao hoặc full collection bị kích hoạt. Pause không phải leak; đó là chi phí thu hồi bộ nhớ unreachable. Leak là khi bộ nhớ vẫn reachable nên GC không thể thu hồi.
Orinoco và vì sao “in use” ≠ “reachable”
Pipeline hiện đại V8 (project Orinoco) tách marking khỏi sweeping và parallelize khi an toàn. Từ góc application, quy tắc không đổi: retained size tăng khi code giữ reference sống — cố ý hay không.
// "I'm done with user" in product terms — but GC disagrees
let currentUser = loadHugeProfile(userId);
function onRouteChange() {
// UI unmounts, DOM gone — yet currentUser still reachable from module scope
renderEmptyState();
}
// Fix: release the retaining edge when lifecycle ends
function onRouteChangeFixed() {
renderEmptyState();
currentUser = null; // drops module-level edge; may be enough if no other retainers
}
Memory leak là gì trong ngôn ngữ GC
Trong C, leak là bộ nhớ bạn không tìm lại được nữa. Trong JavaScript, leak là bộ nhớ bạn vẫn tìm được — từ root bạn quên mất.
Định nghĩa cho công việc frontend:
Memory leak là sự tăng đơn điệu của live heap size (sau GC) do các object vẫn reachable nhưng không còn cần thiết theo vòng đời dự kiến của chương trình.
Triệu chứng:
- Baseline heap tăng qua các lần đổi route hoặc mở/đóng modal
- Số detached DOM node tăng
- Tab mobile reload hoặc crash (áp lực bộ nhớ OS)
- Long task và jank tương quan với full GC
Không phải mọi tăng trưởng đều là leak. Cache, prefetch buffer, và singleton hợp lệ tăng retained size theo thiết kế. Câu hỏi chẩn đoán: retained size có về plateau ổn định khi feature bị teardown không?
Các pattern leak kinh điển — kèm code
1. Timer và interval bị quên
// ❌ Leak: interval holds closure over `data`, keeps firing after component "gone"
function startPolling(data) {
setInterval(() => {
sendAnalytics(data); // `data` retained forever
}, 1000);
}
// ✅ Fix: store id, clear on teardown
function startPollingFixed(data) {
const id = setInterval(() => sendAnalytics(data), 1000);
return () => clearInterval(id);
}
Callback setTimeout/setInterval là root cho đến khi chạy hoặc bị clear. Timeout một lần vẫn giữ closure cho đến khi thực thi.
2. Event listener còn treo
// ❌ Leak: listener on `document` captures huge `state`
function mountWidget(state) {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeWidget(state);
});
// never removeListener on unmount
}
// ✅ Fix: named handler + removeEventListener (same capture flag)
function mountWidgetFixed(state) {
function onKeydown(e) {
if (e.key === 'Escape') closeWidget(state);
}
document.addEventListener('keydown', onKeydown);
return () => document.removeEventListener('keydown', onKeydown);
}
Ưu tiên AbortSignal để cleanup hàng loạt:
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
fetch('/api/x', { signal: controller.signal });
// teardown
controller.abort(); // removes all listeners registered with this signal
3. Detached DOM node bị JS giữ
Gỡ node khỏi document không thu gom nếu JS vẫn reference.
// ❌ Leak: cache keeps detached subtree alive
const elementCache = new Map();
function destroyPanel(el) {
elementCache.set(el.id, el); // strong ref to DOM + listeners + expandos
el.remove();
}
// ✅ Fix: WeakMap keyed by logical id, or drop cache entry on destroy
const elementCacheFixed = new WeakMap();
function destroyPanelFixed(el, meta) {
elementCacheFixed.set(el, meta); // DOM key — entry dies when el is unreachable
el.remove();
}
Trong DevTools, Detached DOM tree tăng gần như luôn nghĩa là retainer phía JS (listener, cache, closure) — không phải bug browser.
4. Closure giữ scope lớn
function createHandler() {
const hugeBuffer = new Uint8Array(50 * 1024 * 1024); // 50 MB
const id = computeId(hugeBuffer);
// ❌ Leak pattern: returned function closes over entire scope including hugeBuffer
return function onClick() {
console.log(id);
};
}
// ✅ Fix: copy only what you need into minimal bindings
function createHandlerFixed() {
const hugeBuffer = new Uint8Array(50 * 1024 * 1024);
const id = computeId(hugeBuffer);
hugeBuffer.fill(0); // optional: help young-gen reclaim sooner if no other refs
return function onClick() {
console.log(id); // closure retains `id` (small), not hugeBuffer
};
}
V8 allocate slot context closure cho biến captured. Vô tình capture object lớn giữ toàn bộ backing store reachable.
5. Cache cấp module và mảng không giới hạn
// ❌ Leak by design drift: cache never evicts
const responseCache = new Map();
export async function fetchUser(id) {
if (responseCache.has(id)) return responseCache.get(id);
const user = await api.getUser(id);
responseCache.set(id, user);
return user;
}
// ✅ Fix: LRU cap, TTL, or WeakRef for soft cache
const MAX = 500;
const responseCacheFixed = new Map();
export async function fetchUserFixed(id) {
if (responseCacheFixed.has(id)) return responseCacheFixed.get(id);
const user = await api.getUser(id);
if (responseCacheFixed.size >= MAX) {
const firstKey = responseCacheFixed.keys().next().value;
responseCacheFixed.delete(firstKey);
}
responseCacheFixed.set(id, user);
return user;
}
Module scope sống suốt đời JS realm (session tab). Mọi Map ở top-level module là root vĩnh viễn trừ khi bạn xóa entry.
6. Retainer đặc thù React
React không tự GC effect của bạn. Thiếu cleanup là nguồn leak SPA số 1.
// ❌ Subscription leak
useEffect(() => {
const sub = eventBus.on('tick', handleTick);
// missing return () => sub.off(...)
}, []);
// ❌ Stale closure holding large props/state in long-lived subscription
useEffect(() => {
const id = setInterval(() => {
process(largeDataset); // re-created effect may stack intervals if deps wrong
}, 1000);
return () => clearInterval(id);
}, []); // empty deps — largeDataset from first render captured forever
// ✅ Stable deps + cleanup + narrow capture
useEffect(() => {
const id = setInterval(() => process(largeDataset), 1000);
return () => clearInterval(id);
}, [largeDataset]);
Bug stale closure thường đi cùng leak: callback cũ vẫn đăng ký đâu đó giữ state tree cũ nếu bus ngoài lưu callback mạnh. Audit listener bên thứ ba (map, chart, analytics) theo đối xứng mount/unmount.
| Pattern | Retainer | Fix |
|---|---|---|
Global addEventListener | Document/window root | removeEventListener or AbortSignal |
setInterval in module | Timer root | clearInterval on teardown |
Cache Map | Global/module root | Eviction policy |
| Detached DOM | JS reference | Drop ref; WeakMap for metadata |
| React effect | Closure + host subscription | Effect cleanup function |
IntersectionObserver | Observer + targets | disconnect() |
WeakMap, WeakRef, và FinalizationRegistry
ECMAScript cung cấp reference weak không tạo cạnh retain cho GC. Dùng khi lifecycle nên theo object, không phải string key.
WeakMap và WeakSet
- Key phải là object (hoặc registered symbol); value có thể là bất kỳ
- Nếu key object unreachable, entry biến mất không có thời điểm xác định
- Lý tưởng cho metadata DOM, private field, và gắn data với element mà không chặn collection
const meta = new WeakMap();
function enhance(el, options) {
meta.set(el, options);
}
function destroy(el) {
el.remove();
// no need to meta.delete(el) — when el is unreachable, entry is collected
}
Bạn không thể iterate WeakMap — không có size. Đó là cố ý: iteration sẽ cần strong reference tới key.
WeakRef
let cache = buildExpensiveModel();
const weak = new WeakRef(cache);
// drop strong reference when appropriate
cache = null;
// later — may return undefined if GC collected the target
const revived = weak.deref();
WeakRef cho soft cache (tính lại khi miss) mà không đảm bảo giữ lại. Không dùng cho state quan trọng về correctness — collection không deterministic.
FinalizationRegistry
const registry = new FinalizationRegistry((heldValue) => {
// runs on a future turn, possibly much later — NOT a destructor
closeNativeHandle(heldValue);
});
registry.register(jsWrapper, nativeHandleId, jsWrapper);
// when jsWrapper is collected, callback may run with heldValue
Lưu ý: Finalizer có thể không bao giờ chạy khi đóng tab nhanh. Có thể chạy theo thứ tự bất kỳ. Không bao giờ implement
close(),free(), hoặc logic UX quan trọng chỉ trong finalizer. Kết hợp với dispose tường minh.
Chẩn đoán bằng Chrome DevTools
Panel Memory — heap snapshot
- Mở DevTools → Memory
- Chụp Heap snapshot (ưu tiên view “Summary” trước)
- Lọc theo constructor (
Detached HTMLElement,(closure),Array, tên class của bạn) - Xem Shallow size vs Retained size
| Metric | Meaning |
|---|---|
| Shallow size | Memory held by the object itself |
| Retained size | Memory freed if this object were unreachable (includes dependents) |
Click instance → panel Retainers hiện retainer path lên root. Đường đó là bản đồ bug.
Kỹ thuật ba snapshot
Cho leak khi chuyển route:
- Snapshot A — baseline (home page ổn định)
- Thực hiện action (mở modal, navigate, lặp 5–10 lần)
- Snapshot B — đỉnh
- Hoàn tác (đóng modal, quay lại); force GC (icon thùng rác trong Memory panel)
- Snapshot C — sau teardown + GC
- So C vs A — tìm object không nên còn
Ở chế độ so sánh, sort theo # Delta hoặc Size Delta. (closure) Tăng (closure) gắn tên component là dấu hiệu rõ.
Allocation instrumentation trên timeline
Memory → Allocation instrumentation on timeline ghi allocation theo thời gian. Hữu ích khi biết khi nào leak xảy ra nhưng không biết cái gì. Thu hẹp thanh allocation xanh, xem constructor ở pane dưới.
Panel Performance — memory track
Ghi Performance profile với Memory được chọn. Bạn thấy JS heap, DOM node, document, và GPU memory (nếu có) tương quan với hoạt động Main thread. Răng cưa leo dần = leak hoặc cache không giới hạn; đỉnh thẳng đứng = GC pause.
performance.memory — lưu ý chỉ dev
if ('memory' in performance) {
// Chrome-only, non-standard, requires cross-origin isolation for some fields
const { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit } = performance.memory;
console.log({ usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit });
}
performance.memory không có trên mọi browser và có thể bị tắt. Giá trị thô và gồm phần nội bộ engine không thể gán nguồn. Dùng cho dashboard local sơ bộ, không phải SLA production.
Đo lường production (best-effort)
measureUserAgentSpecificMemory()
Chromium expose performance.measureUserAgentSpecificMemory() — API async trả breakdown bộ nhớ gán cho origin (có lưu ý).
Yêu cầu và giới hạn:
- Secure context (HTTPS)
- Cross-origin isolation (
Cross-Origin-Opener-Policy+Cross-Origin-Embedder-Policy) để attribution có nghĩa trong nhiều setup - Trả byte xấp xỉ; throttle và gom call
- Chưa có trên Safari/Firefox tại thời điểm viết — feature-detect
async function sampleHeapBreakdown() {
if (!performance.measureUserAgentSpecificMemory) return null;
try {
const result = await performance.measureUserAgentSpecificMemory();
// result.bytes — total; result.breakdown — array of attribution entries
return result;
} catch (err) {
// throws if document is not ready or API constraints not met
console.warn('measureUserAgentSpecificMemory failed', err);
return null;
}
}
Dùng cho lấy mẫu xu hướng (vd p95 memory theo bucket thời lượng session), không phải micro-metric mỗi navigation. Kết hợp với tín hiệu RUM bạn đã tin: long task rate, crash rate, OOM reload.
Principal engineer instrument gì
| Signal | Production fit |
|---|---|
| Session duration vs crash/reload | High |
| Long task / INP degradation over session time | High |
measureUserAgentSpecificMemory sampled | Medium (Chromium-only) |
performance.memory polling | Low (non-standard) |
| Heap snapshots | Local/staging only |
Quy trình săn leak
Reproduce → Bound scope → Snapshot diff → Read retainers → Fix → Verify
│ │ │ │ │ │
│ │ │ │ │ └─ 3-snapshot: C ≈ A
│ │ │ │ └─ remove edge / cleanup
│ │ │ └─ path to root (listener? map?)
│ │ └─ filter Detached / (closure) / yourClass
│ └─ one route, one modal, one feature flag
└─ incognito, extensions off, deterministic user script
Từng bước
- Reproduce xác định — viết script Playwright 10 dòng hoặc thủ công; leak đủ non-deterministic rồi, đừng thêm hỗn loạn
- Cô lập — tắt extension; một tab; chỉ tắt cache nếu làm rối DOM bfcache (thường giữ cache cho thực tế)
- Chụp — ba snapshot hoặc timeline allocation quanh action nghi ngờ
- Xác định constructor — sort theo retained size delta; đừng đuổi 100 KB
stringtrước khi xử lý(closure)40 MB - Duyệt retainers — frame bất ngờ ngoài cùng thường là code bạn, không phải V8 internals
- Sửa và verify — lặp snapshot C vs A; thêm regression test assert listener count hoặc mock bus unsubscribe nếu có
Checklist phòng ngừa cho principal engineer
Lúc thiết kế
- Mọi registry sống lâu (
Map, event bus, singleton) có chính sách eviction hoặc weak-key - Subscription có side-effect khai báo lifecycle owner (ai mount, ai dispose)
- Buffer lớn đi qua interface hẹp — không close over megabyte trong callback UI
Lúc code review
addEventListener/on()có remove đối xứng /AbortSignalsetInterval/requestAnimationFrameloop có teardown- Cache module có giới hạn (LRU, TTL, max entries)
- React
useEffectreturn cleanup khi chạm hệ thống ngoài - Widget bên thứ ba bọc adapter có
destroy() - Không buffer log in-memory không giới hạn trên
window
CI / staging
- Soak navigation tự động (Playwright) + diff heap snapshot trong CI nightly (job Chromium)
- Phân tích bundle phát hiện export global vô tình giữ graph
- Performance budget gồm heap sau GC sau user journey script
Tóm tắt mental model
GC thu hồi object unreachable, không phải unused. Collector generational V8 làm allocation sống ngắn rẻ nhưng không cứu bạn khỏi root vĩnh viễn. Leak trong SPA hầu như luôn là cạnh bị quên: listener, timer, cache, closure, detached DOM.
WeakMap/WeakRef/FinalizationRegistry giúp căn retention với lifecycle object — không bao giờ thay teardown tường minh. Retainer path Chrome DevTools biến tăng trưởng bí ẩn thành fix cụ thể. Trong production, lấy mẫu xu hướng memory thô và xử lý fix leak như regression perf: reproduce, đo, patch, guard.
Nếu retained size sau GC không về baseline khi feature teardown, bạn không có bí ẩn — bạn có reachability path đang chờ trong panel Retainers.