jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Performance · Part 18 — Memory & Long-Session Performance

Vì sao SPA chậm dần sau nhiều giờ: rò rỉ bộ nhớ, detached DOM, listener/timer không gỡ, đo bằng heap snapshot và measureUserAgentSpecificMemory, và giữ app nhẹ qua phiên dài.

Hầu hết bài viết hiệu năng tập trung vào lúc tải. Nhưng với SPA và app dùng lâu (email, dashboard, editor mở cả ngày), vấn đề lớn lại là suy giảm theo thời gian: trang khởi đầu nhanh rồi chậm dần, giật hơn, cuối cùng tab “ngốn” hàng GB. Thủ phạm thường là rò rỉ bộ nhớ. Phần này dạy cách phát hiện và ngăn chúng.

Khác biệt then chốt: hiệu năng tải đo một lần; hiệu năng phiên dài là tích lũy. Một rò rỉ nhỏ mỗi lần điều hướng, nhân với hàng trăm lần điều hướng trong một phiên, thành thảm họa.


1. Rò rỉ trong JS nghĩa là gì

GC thu hồi mọi thứ không còn reachable từ root. Rò rỉ xảy ra khi object vẫn reachable (còn tham chiếu) nhưng app không còn cần — GC không thể đoán ý định, chỉ thấy tham chiếu.

Bốn nguồn rò rỉ phổ biến nhất:
  ① Listener/observer/timer không gỡ khi component unmount
  ② Detached DOM (node bị xóa khỏi cây nhưng JS vẫn giữ)
  ③ Cache/Map/mảng phình mãi không xóa
  ④ Closure giữ object lớn sống lâu hơn cần thiết

Trong SPA, component mount/unmount liên tục; mỗi lần quên gỡ một listener là một mảnh bộ nhớ kẹt lại vĩnh viễn.


2. Listener & timer — gỡ theo vòng đời

Một nguồn rò rỉ rất phổ biến trong SPA là đăng ký listener/timer/observer khi mount mà không gỡ khi unmount.

// ❌ Rò rỉ: listener + interval sống mãi, giữ cả closure + element
function mount(el: HTMLElement) {
  window.addEventListener('resize', () => layout(el));
  setInterval(() => poll(el), 1000);
  new ResizeObserver(() => layout(el)).observe(el);
  // không có đường gỡ → mỗi lần mount lại thêm rò rỉ
}

// ✅ Teardown đối xứng với AbortController
function mount(el: HTMLElement) {
  const ctrl = new AbortController();
  window.addEventListener('resize', () => layout(el), { signal: ctrl.signal });
  const id = setInterval(() => poll(el), 1000);
  const ro = new ResizeObserver(() => layout(el));
  ro.observe(el);

  return () => { ctrl.abort(); clearInterval(id); ro.disconnect(); }; // gọi khi unmount
}

AbortController + { signal } gỡ nhiều listener bằng một abort() — gọn và khó quên hơn nhiều removeEventListener. Framework (React useEffect cleanup, Vue onUnmounted) cung cấp móc gỡ; luôn dùng chúng.


3. Detached DOM — kẻ rò rỉ phổ biến

Khi gỡ node khỏi DOM nhưng JS vẫn giữ tham chiếu, node đó (và cả cây con) thành detached — không hiển thị nhưng vẫn tốn bộ nhớ.

// ❌ giữ tham chiếu tới node đã bị xóa khỏi cây
const cache = new Map<string, HTMLElement>();
function show(id: string) {
  const el = document.getElementById(id)!;
  cache.set(id, el);          // cache giữ mãi
  el.remove();                // xóa khỏi DOM nhưng cache vẫn giữ → detached
}

// ✅ giải phóng khi không cần, hoặc dùng WeakMap để không cản GC
const meta = new WeakMap<HTMLElement, Info>(); // key không bị giữ sống

WeakMap/WeakSet lý tưởng cho metadata gắn theo DOM node: khi node bị xóa khỏi mọi nơi khác, entry tự biến mất.


4. Săn rò rỉ với heap snapshot

DevTools → Memory → Heap snapshot. Quy trình three-snapshot giúp phân biệt object sống hợp lệ với tăng trưởng lặp lại:

1. Warm-up, quay về trạng thái ổn định, force GC → Snapshot A (baseline)
2. Lặp thao tác N lần, quay về cùng trạng thái, force GC → Snapshot B
3. Lặp thêm N lần nữa, quay về, force GC → Snapshot C
4. So sánh B−A và C−B; nhóm object tăng gần cùng tốc độ là ứng viên rò rỉ
Trong snapshot, chú ý:
  - lọc "Detached" → thấy node DOM mồ côi
  - Retained size lớn → "thủ phạm" giữ nhiều thứ
  - Retainers → lần ngược chuỗi tham chiếu tới root (dây neo cần cắt)

Tab Performance → bật “Memory” checkbox: nếu đường live JS heap/node count tăng bậc sau các lần force GC và cùng một user flow, đó là tín hiệu rò rỉ. Heap tăng tự nhiên do cache hoặc state hợp lệ, nên vẫn phải lần retainers trước khi kết luận.


5. Đo bộ nhớ trong production

performance.measureUserAgentSpecificMemory() trả về ước lượng bộ nhớ của web app cùng iframe/worker liên quan, hữu ích để lấy mẫu xu hướng RUM. API này experimental, chưa baseline, chỉ chạy trong secure context có cross-origin isolation (COOP/COEP), và phép đo có thể tốn chi phí.

// Cần crossOriginIsolated === true (header COOP + COEP)
type MemoryMeasurement = { bytes: number };
const memoryPerformance = performance as Performance & {
  measureUserAgentSpecificMemory?: () => Promise<MemoryMeasurement>;
};

if (crossOriginIsolated && memoryPerformance.measureUserAgentSpecificMemory) {
  try {
    const result = await memoryPerformance.measureUserAgentSpecificMemory();
    report('memory', result.bytes); // theo dõi xu hướng theo thời gian phiên
  } catch {
    // API có thể từ chối khi context/policy hoặc tần suất lấy mẫu không phù hợp.
  }
}
// performance.memory (cũ, chỉ Chrome, không chuẩn) — chỉ dùng để debug nhanh
const heap = (performance as any).memory?.usedJSHeapSize;

Lấy mẫu thưa và chỉ so sánh trong cùng browser/device class. Nếu ước lượng bộ nhớ tăng theo số phút/điều hướng, đó là tín hiệu để tái hiện cục bộ; không trộn client hỗ trợ API với client không hỗ trợ rồi suy ra một baseline chung.


6. Page Lifecycle — hợp tác với trình duyệt

Trình duyệt có thể đóng băng/loại bỏ (discard) tab nền để lấy lại bộ nhớ. App phiên dài nên hợp tác: lưu trạng thái sớm và nhả tài nguyên khi ẩn.

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    saveState();          // lưu ngay — tab có thể bị discard
    pausePolling();       // dừng timer/kết nối không cần khi ẩn
    flushRUM();           // gửi metric bằng sendBeacon (Part 13)
  } else {
    resumePolling();
  }
});

Dừng polling/animation và đóng hoặc quiesce kết nối không cần khi tab ẩn giúp tiết kiệm pin/CPU. Nhưng visibilitychange không tự bảo đảm BFCache: eligibility còn tùy API đang dùng, listener unload, response header và browser. Kiểm tra lý do cụ thể bằng DevTools/BFCache audit, rồi khôi phục tài nguyên ở pageshow/khi visible.


7. Tóm tắt

  • Hiệu năng phiên dài là tích lũy: rò rỉ nhỏ × nhiều điều hướng = app chậm dần, tốn GB.
  • Nguồn rò rỉ chính: listener/timer/observer không gỡ, detached DOM, cache phình, closure giữ object lớn.
  • Gỡ theo vòng đời bằng AbortController + { signal } / cleanup của framework; dùng WeakMap/WeakSet cho metadata gắn DOM.
  • Săn rò rỉ bằng three-snapshot đúng nghĩa (A/B/C sau force GC), lọc “Detached”, xem retained size + retainers; tăng trưởng lặp lại là tín hiệu cần điều tra, chưa phải kết luận tự động.
  • Lấy mẫu production bằng measureUserAgentSpecificMemory khi có hỗ trợ (experimental, secure + cross-origin isolated); so sánh theo browser/device class.
  • Hợp tác Page Lifecycle: lưu sớm + nhả tài nguyên khi visibilitychange → hidden; kiểm tra BFCache eligibility riêng thay vì mặc định trang sẽ được cache.

Phần tiếp theo: Network protocol & transport — HTTP/2 vs HTTP/3 (QUIC), head-of-line blocking, nén Brotli/Zstd, TLS 0-RTT, CDN/edge, và cache-control nâng cao.