At 3 a.m., my phone buzzes again — “Blank page, JS heap over 2 GB, auto-restarted three times.” Frontend OOM in production always strikes when user traffic is at its peak. While restarting pods to buy time, you fire up Chrome DevTools, open the Memory panel, record allocations, compare snapshots, filter for detached DOM nodes, trace retaining paths… With luck, you’ll ship the fix before dawn. The root cause is usually a component that didn’t clear a timer on unmount, a closure holding a large object, or an event listener ballooning the reference chain — in short, a classic memory leak.
What really frustrates you isn’t fixing the bug — it’s that every release can introduce new leaks, and verification still relies on manually clicking through dozens of pages, simulating interactions, and staring at snapshots. This can’t go on.
Why manual checks can’t stop production incidents
Frontend memory leaks often happen in SPAs: global event listeners that aren’t removed, uncleared setInterval, closures capturing DOM elements, WebSocket reconnections spawning infinite copies. QA functional testing rarely catches these leaks, because unless you interact continuously for a long time, heap growth never reaches the GC threshold. Only real users who stay on the page and interact heavily push the JS heap past the container limit and get taught a lesson by the OOM Killer.
The usual options:
- Manually walk through critical paths with Chrome DevTools before release? Limited coverage, depends on human stamina, and nobody wants to click around for 40 minutes every day.
-
Bump up container memory with
--max-old-space-size? Treats the symptom, not the cause — just delays the inevitable. - Lighthouse / PageSpeed? They care about performance and best practices, not whether your heap memory is slowly creeping up.
The conclusion is clear: we need a solution that simulates user interactions, automatically captures heap memory changes, and integrates into the CI pipeline — running on every PR to detect if a leak has been introduced.
Why we chose Playwright + CDP over other tools
Options on the table:
- Puppeteer + manually using CDP — Capable enough, but multi-browser support is weaker compared to Playwright, and its API design feels a bit raw.
- Cypress — A great testing framework, but still relies on CDP under the hood. It lacks fine-grained control over memory snapshots across multiple pages and contexts, and its plugin ecosystem is far from memory analysis.
- Directly using Node.js to talk to the Chrome DevTools Protocol — Maximum flexibility, but you have to handle launching, closing, and lifecycle management, which adds unnecessary scaffolding code.
We chose Playwright because it offers cross-browser orchestration out of the box and exposes page.context().newCDPSession(), which lets you talk directly to the V8 Heap Profiler with surgical precision. The alternatives are either too far removed or too heavy.
The architecture is straightforward: launch the target page with Playwright → trigger a critical user flow → force GC → capture a baseline memory snapshot → repeat actions → GC → capture a final snapshot → compare differences → generate a report → block the PR in CI with a non-zero exit code.
This pipeline breaks down into three core modules:
- Collection layer: obtains heap memory data via CDP (JS heap size, node counts, etc.)
- Decision layer: a rule engine that compares the growth rate of metrics before and after interactions.
- CI integration layer: produces structured output, compatible with GitHub Actions / GitLab CI exit codes and artifacts.
Core implementation: building a practical Playwright memory detection script from scratch
Here’s a production-ready Node.js script that opens a page, simulates two business operations, and compares heap growth.
1. Launch the browser, set up a CDP session, and open the page
This snippet sets up Playwright and a CDP connection, with one important flag: use headless: true along with --enable-precise-memory-info; otherwise the memory data you get is coarse-grained.
const { chromium } = require('playwright');
async function main() {
const browser = await chromium.launch({
headless: true, // CI 环境必须无头
args: ['--enable-precise-memory-info'] // 解锁精确堆内存数据
});
const context = await browser.newContext({
viewport: { width: 1280, height: 720 }
});
const page = await context.newPage();
// 创建 CDP 会话,直通 V8 Heap Profiler
const cdp = await context.newCDPSession(page);
await cdp.send('HeapProfiler.enable');
// 先确认页面可访问
await page.goto('http://localhost:3000/heavy-page', { waitUntil: 'networkidle' });
// ... 后续操作
}
main().catch(console.error);
2. Simulate user actions, force GC, and capture a heap baseline
Next, simulate your typical business flow — for example, repeatedly opening modals, switching tabs, submitting forms. Here’s a generic function that attempts to trigger garbage collection after each action (the --expose-gc approach works only in specific setups, but CDP provides a more reliable way).
// 封装:执行 GC 并返回当前 JS heap 大小(字节)
async function getHeapSize(page) {
// 通过 CDP 触发精确 GC(比 page.evaluate 里调 gc() 更可靠)
const cdp = await page.context().newCDPSession(page);
await cdp.send('HeapProfiler.collectGarbage');
// 从 performance.memory 读取(需要 --enable-precise-memory-info)
const memoryInfo = await page.evaluate(() => ({
usedJSHeapSize: performance.memory?.usedJSHeapSize || 0,
totalJSHeapSize: performance.memory?.totalJSHeapSize || 0
}));
return memoryInfo.usedJSHeapSize;
}
Top comments (0)