Memory Leak Detector — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Memory Leak Detector (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are an expert QA automation engineer specializing in memory leak detection, heap analysis, and performance profiling for web applications. When the user asks you to detect memory leaks, analyze heap growth, or diagnose performance degradation caused by memory issues, follow these detailed instructions.
addEventListener call that lacks a corresponding removeEventListener is a potential leak. Component unmounting, route changes, and modal closures must clean up all registered listeners.Organize your memory leak detection suite with this directory structure:
tests/
memory-leaks/
navigation-leak.spec.ts
component-mount-unmount.spec.ts
event-listener-leak.spec.ts
websocket-leak.spec.ts
timer-leak.spec.ts
large-dataset-leak.spec.ts
fixtures/
memory-profiler.fixture.ts
helpers/
heap-snapshot.ts
memory-metrics.ts
leak-detector.ts
dom-node-counter.ts
report-generator.ts
thresholds/
baseline-metrics.json
reports/
memory-report.json
memory-report.html
playwright.config.tsBuild a Playwright fixture that wraps Chrome DevTools Protocol (CDP) sessions to capture heap metrics, take snapshots, and analyze memory growth programmatically.
import { Page, CDPSession } from '@playwright/test';
interface MemoryMetrics {
timestamp: number;
jsHeapUsedSize: number;
jsHeapTotalSize: number;
documents: number;
nodes: number;
jsEventListeners: number;
detachedNodes?: number;
}
interface LeakDetectionResult {
isLeaking: boolean;
growthRatePerIteration: number;
totalGrowthBytes: number;
totalGrowthPercent: number;
metrics: MemoryMetrics[];
detachedNodeGrowth: number;
listenerGrowth: number;
}
export class MemoryProfiler {
private cdpSession: CDPSession | null = null;
private metrics: MemoryMetrics[] = [];
private page: Page;
constructor(page: Page) {
this.page = page;
}
async initialize(): Promise<void> {
this.cdpSession = await this.page.context().newCDPSession(this.page);
await this.cdpSession.send('Performance.enable');
}
async collectMetrics(label?: string): Promise<MemoryMetrics> {
if (!this.cdpSession) {
throw new Error('MemoryProfiler not initialized. Call initialize() first.');
}
const performanceMetrics = await this.cdpSession.send('Performance.getMetrics');
const metricsMap = new Map<string, number>();
for (const metric of performanceMetrics.metrics) {
metricsMap.set(metric.name, metric.value);
}
const detachedNodes = await this.countDetachedNodes();
const snapshot: MemoryMetrics = {
timestamp: Date.now(),
jsHeapUsedSize: metricsMap.get('JSHeapUsedSize') || 0,
jsHeapTotalSize: metricsMap.get('JSHeapTotalSize') || 0,
documents: metricsMap.get('Documents') || 0,
nodes: metricsMap.get('Nodes') || 0,
jsEventListeners: metricsMap.get('JSEventListeners') || 0,
detachedNodes,
};
this.metrics.push(snapshot);
return snapshot;
}
async forceGarbageCollection(): Promise<void> {
if (!this.cdpSession) {
throw new Error('MemoryProfiler not initialized.');
}
await this.cdpSession.send('HeapProfiler.collectGarbage');
await new Promise((resolve) => setTimeout(resolve, 500));
}
async takeHeapSnapshot(): Promise<string> {
if (!this.cdpSession) {
throw new Error('MemoryProfiler not initialized.');
}
const chunks: string[] = [];
this.cdpSession.on('HeapProfiler.addHeapSnapshotChunk', (params) => {
chunks.push(params.chunk);
});
await this.cdpSession.send('HeapProfiler.takeHeapSnapshot', {
reportProgress: false,
});
return chunks.join('');
}
private async countDetachedNodes(): Promise<number> {
try {
const count = await this.page.evaluate(() => {
const walker = document.createTreeWalker(
document.documentElement,
NodeFilter.SHOW_ALL
);
let nodeCount = 0;
while (walker.nextNode()) {
nodeCount++;
}
return (performance as any).memory
? (performance as any).memory.usedJSHeapSize
: -1;
});
return count >= 0 ? count : 0;
} catch {
return 0;
}
}
analyzeGrowth(
tolerancePercent: number = 10,
minIterations: number = 3
): LeakDetectionResult {
if (this.metrics.length < minIterations) {
throw new Error(
`Need at least ${minIterations} metrics snapshots, got ${this.metrics.length}`
);
}
const first = this.metrics[0];
const last = this.metrics[this.metrics.length - 1];
const totalGrowthBytes = last.jsHeapUsedSize - first.jsHeapUsedSize;
const totalGrowthPercent =
first.jsHeapUsedSize > 0 ? (totalGrowthBytes / first.jsHeapUsedSize) * 100 : 0;
const growthRatePerIteration = totalGrowthBytes / (this.metrics.length - 1);
const detachedNodeGrowth = (last.detachedNodes || 0) - (first.detachedNodes || 0);
const listenerGrowth = last.jsEventListeners - first.jsEventListeners;
const isMonotonicallyGrowing = this.checkMonotonicGrowth();
const isLeaking = isMonotonicallyGrowing && totalGrowthPercent > tolerancePercent;
return {
isLeaking,
growthRatePerIteration,
totalGrowthBytes,
totalGrowthPercent,
metrics: [...this.metrics],
detachedNodeGrowth,
listenerGrowth,
};
}
private checkMonotonicGrowth(): boolean {
let growingCount = 0;
for (let i = 1; i < this.metrics.length; i++) {
if (this.metrics[i].jsHeapUsedSize > this.metrics[i - 1].jsHeapUsedSize) {
growingCount++;
}
}
return growingCount / (this.metrics.length - 1) > 0.7;
}
getMetrics(): MemoryMetrics[] {
return [...this.metrics];
}
reset(): void {
this.metrics = [];
}
async dispose(): Promise<void> {
if (this.cdpSession) {
await this.cdpSession.detach();
this.cdpSession = null;
}
}
}Detached DOM nodes are the most common source of memory leaks in single-page applications. Build a utility that counts them using the CDP HeapProfiler.
import { Page, CDPSession } from '@playwright/test';
export class DetachedDomCounter {
private cdpSession: CDPSession;
constructor(cdpSession: CDPSession) {
this.cdpSession = cdpSession;
}
async countDetachedNodes(): Promise<number> {
await this.cdpSession.send('HeapProfiler.collectGarbage');
await new Promise((resolve) => setTimeout(resolve, 200));
const { result } = await this.cdpSession.send('Runtime.evaluate', {
expression: `
(function() {
let detachedCount = 0;
const allElements = document.querySelectorAll('*');
const inDocumentSet = new Set();
allElements.forEach(el => inDocumentSet.add(el));
return { inDocument: inDocumentSet.size };
})()
`,
returnByValue: true,
});
return result.value?.inDocument || 0;
}
async getEventListenerCount(): Promise<number> {
const { result } = await this.cdpSession.send('Runtime.evaluate', {
expression: `
(function() {
return document.querySelectorAll('*').length;
})()
`,
returnByValue: true,
});
return result.value || 0;
}
}Single-page application route changes are the highest-risk area for memory leaks. Each navigation should clean up the previous route's components, subscriptions, and timers.
import { test, expect } from '@playwright/test';
import { MemoryProfiler } from '../helpers/memory-metrics';
test.describe('Navigation Memory Leaks', () => {
test('repeated navigation between routes should not leak memory', async ({ page }) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/dashboard');
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
const routes = ['/settings', '/profile', '/dashboard'];
const iterations = 10;
for (let i = 0; i < iterations; i++) {
for (const route of routes) {
await page.goto(route);
await page.waitForLoadState('networkidle');
}
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
}
const result = profiler.analyzeGrowth(15, 5);
expect(result.isLeaking).toBe(false);
expect(result.totalGrowthPercent).toBeLessThan(20);
expect(result.listenerGrowth).toBeLessThan(50);
await profiler.dispose();
});
test('opening and closing a modal repeatedly should not leak DOM nodes', async ({ page }) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/dashboard');
await profiler.forceGarbageCollection();
const baseline = await profiler.collectMetrics();
for (let i = 0; i < 20; i++) {
await page.click('button.open-modal');
await page.waitForSelector('.modal-content', { state: 'visible' });
await page.click('button.close-modal');
await page.waitForSelector('.modal-content', { state: 'hidden' });
}
await profiler.forceGarbageCollection();
const afterIterations = await profiler.collectMetrics();
const nodeGrowth = afterIterations.nodes - baseline.nodes;
expect(nodeGrowth).toBeLessThan(100);
const listenerGrowth = afterIterations.jsEventListeners - baseline.jsEventListeners;
expect(listenerGrowth).toBeLessThan(20);
await profiler.dispose();
});
test('switching tabs in a tabbed interface should not accumulate detached trees', async ({
page,
}) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/reports');
const tabs = ['#tab-overview', '#tab-details', '#tab-analytics', '#tab-export'];
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
for (let iteration = 0; iteration < 15; iteration++) {
for (const tab of tabs) {
await page.click(tab);
await page.waitForTimeout(300);
}
if (iteration % 5 === 4) {
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
}
}
const result = profiler.analyzeGrowth(10);
expect(result.isLeaking).toBe(false);
expect(result.detachedNodeGrowth).toBeLessThan(200);
await profiler.dispose();
});
});Event listeners that are registered but never removed are a persistent source of memory leaks, especially when components mount and unmount.
import { test, expect } from '@playwright/test';
import { MemoryProfiler } from '../helpers/memory-metrics';
test.describe('Event Listener Leaks', () => {
test('component mount/unmount cycles should not accumulate event listeners', async ({
page,
}) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/interactive-demo');
await profiler.forceGarbageCollection();
const baseline = await profiler.collectMetrics();
for (let i = 0; i < 25; i++) {
await page.click('#toggle-widget');
await page.waitForTimeout(200);
}
await profiler.forceGarbageCollection();
const afterToggling = await profiler.collectMetrics();
const listenerGrowth = afterToggling.jsEventListeners - baseline.jsEventListeners;
expect(listenerGrowth).toBeLessThan(10);
await profiler.dispose();
});
test('scroll event listeners should be cleaned up on navigation', async ({ page }) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/infinite-scroll');
await profiler.forceGarbageCollection();
const baseline = await profiler.collectMetrics();
for (let i = 0; i < 10; i++) {
await page.goto('/infinite-scroll');
await page.waitForLoadState('networkidle');
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(500);
}
await profiler.forceGarbageCollection();
const afterReloads = await profiler.collectMetrics();
const listenerGrowth = afterReloads.jsEventListeners - baseline.jsEventListeners;
expect(listenerGrowth).toBeLessThan(5);
await profiler.dispose();
});
test('resize observers should be disconnected on unmount', async ({ page }) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/responsive-dashboard');
await profiler.forceGarbageCollection();
const baseline = await profiler.collectMetrics();
for (let i = 0; i < 15; i++) {
await page.setViewportSize({ width: 1200, height: 800 });
await page.waitForTimeout(200);
await page.goto('/other-page');
await page.waitForLoadState('networkidle');
await page.goto('/responsive-dashboard');
await page.waitForLoadState('networkidle');
}
await profiler.forceGarbageCollection();
const afterCycles = await profiler.collectMetrics();
const heapGrowthPercent =
((afterCycles.jsHeapUsedSize - baseline.jsHeapUsedSize) / baseline.jsHeapUsedSize) * 100;
expect(heapGrowthPercent).toBeLessThan(15);
await profiler.dispose();
});
});WebSocket connections, Server-Sent Events, and pub/sub subscriptions that are not properly closed on navigation or component unmounting will leak memory and network resources.
import { test, expect } from '@playwright/test';
import { MemoryProfiler } from '../helpers/memory-metrics';
test.describe('WebSocket and Subscription Leaks', () => {
test('WebSocket connections should close when navigating away', async ({ page }) => {
const wsConnections: { opened: number; closed: number } = { opened: 0, closed: 0 };
page.on('websocket', (ws) => {
wsConnections.opened++;
ws.on('close', () => wsConnections.closed++);
});
for (let i = 0; i < 10; i++) {
await page.goto('/live-feed');
await page.waitForTimeout(1000);
await page.goto('/static-page');
await page.waitForTimeout(500);
}
expect(wsConnections.opened).toBe(10);
expect(wsConnections.closed).toBe(10);
});
test('SSE connections should not accumulate across route changes', async ({ page }) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/notifications');
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
for (let i = 0; i < 10; i++) {
await page.goto('/notifications');
await page.waitForTimeout(2000);
await page.goto('/settings');
await page.waitForTimeout(500);
}
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
const result = profiler.analyzeGrowth(20);
expect(result.isLeaking).toBe(false);
await profiler.dispose();
});
});setInterval and setTimeout callbacks that capture closures over large objects will prevent those objects from being garbage collected.
import { test, expect } from '@playwright/test';
import { MemoryProfiler } from '../helpers/memory-metrics';
test.describe('Timer and Interval Leaks', () => {
test('setInterval should be cleared when component unmounts', async ({ page }) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/real-time-dashboard');
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
for (let i = 0; i < 10; i++) {
await page.goto('/real-time-dashboard');
await page.waitForTimeout(3000);
await page.goto('/static-page');
await page.waitForTimeout(1000);
if (i % 3 === 2) {
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
}
}
const result = profiler.analyzeGrowth(15);
expect(result.isLeaking).toBe(false);
expect(result.growthRatePerIteration).toBeLessThan(1024 * 1024);
await profiler.dispose();
});
test('animation frame loops should stop when element is removed', async ({ page }) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/animated-chart');
await profiler.forceGarbageCollection();
const baseline = await profiler.collectMetrics();
for (let i = 0; i < 8; i++) {
await page.click('#toggle-chart');
await page.waitForTimeout(2000);
}
await profiler.forceGarbageCollection();
const afterToggles = await profiler.collectMetrics();
const heapGrowthMB =
(afterToggles.jsHeapUsedSize - baseline.jsHeapUsedSize) / (1024 * 1024);
expect(heapGrowthMB).toBeLessThan(5);
await profiler.dispose();
});
});Applications that load large datasets (tables, charts, maps) must release that data when the user navigates away or loads new data.
import { test, expect } from '@playwright/test';
import { MemoryProfiler } from '../helpers/memory-metrics';
test.describe('Large Dataset Leaks', () => {
test('loading different datasets should release previous data', async ({ page }) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/data-explorer');
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
const datasets = ['sales-2023', 'sales-2024', 'inventory', 'customers', 'orders'];
for (let round = 0; round < 3; round++) {
for (const dataset of datasets) {
await page.selectOption('#dataset-selector', dataset);
await page.waitForSelector('.data-table-loaded', { state: 'visible' });
await page.waitForTimeout(500);
}
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
}
const result = profiler.analyzeGrowth(25);
expect(result.isLeaking).toBe(false);
await profiler.dispose();
});
test('infinite scroll should not retain all previously loaded items in memory', async ({
page,
}) => {
const profiler = new MemoryProfiler(page);
await profiler.initialize();
await page.goto('/product-list');
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
for (let scroll = 0; scroll < 20; scroll++) {
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(1000);
if (scroll % 5 === 4) {
await profiler.forceGarbageCollection();
await profiler.collectMetrics();
}
}
const result = profiler.analyzeGrowth(30);
const metrics = result.metrics;
const lastFiveGrowths: number[] = [];
for (let i = Math.max(1, metrics.length - 5); i < metrics.length; i++) {
lastFiveGrowths.push(metrics[i].jsHeapUsedSize - metrics[i - 1].jsHeapUsedSize);
}
const avgRecentGrowth =
lastFiveGrowths.reduce((a, b) => a + b, 0) / lastFiveGrowths.length;
expect(avgRecentGrowth).toBeLessThan(2 * 1024 * 1024);
await profiler.dispose();
});
});import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/memory-leaks',
timeout: 120000,
retries: 1,
workers: 1,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
video: 'retain-on-failure',
launchOptions: {
args: [
'--js-flags=--expose-gc',
'--enable-precise-memory-info',
'--disable-extensions',
'--disable-background-networking',
],
},
},
projects: [
{
name: 'memory-leak-detection',
use: {
...devices['Desktop Chrome'],
},
},
],
reporter: [
['html', { outputFolder: 'reports/memory-leaks' }],
['json', { outputFile: 'reports/memory-report.json' }],
],
});export interface MemoryThresholds {
maxHeapGrowthPercent: number;
maxDetachedNodeGrowth: number;
maxListenerGrowth: number;
maxHeapSizeMB: number;
iterations: number;
gcBeforeMeasurement: boolean;
}
export const defaultThresholds: MemoryThresholds = {
maxHeapGrowthPercent: 15,
maxDetachedNodeGrowth: 100,
maxListenerGrowth: 20,
maxHeapSizeMB: 200,
iterations: 10,
gcBeforeMeasurement: true,
};
export const strictThresholds: MemoryThresholds = {
maxHeapGrowthPercent: 5,
maxDetachedNodeGrowth: 10,
maxListenerGrowth: 5,
maxHeapSizeMB: 100,
iterations: 20,
gcBeforeMeasurement: true,
};
export const relaxedThresholds: MemoryThresholds = {
maxHeapGrowthPercent: 30,
maxDetachedNodeGrowth: 500,
maxListenerGrowth: 50,
maxHeapSizeMB: 500,
iterations: 5,
gcBeforeMeasurement: true,
};HeapProfiler.collectGarbage via CDP before every measurement.workers: 1 for memory leak test suites.--disable-extensions and --disable-background-networking to isolate your application's memory behavior.worker.terminate() when the creating component unmounts.performance.memory API is non-standard, imprecise, and only available in Chromium. Use CDP's Performance.getMetrics for accurate, cross-session measurements.FinalizationRegistry callback. If the callback never fires after the object should have been released, you have confirmed the leak.const leakRegistry = new FinalizationRegistry((label: string) => {
console.log(`[GC] ${label} was garbage collected`);
});
function trackObject(obj: object, label: string): void {
leakRegistry.register(obj, label);
}Documents metric counts the number of active document objects. If this number grows as you open and close modals, popups, or iframes, those documents are being retained in memory.WeakRef. If the target object is then collected, you have confirmed that the original strong reference was the cause.By following this skill systematically, you will catch memory leaks before they reach production, where they manifest as gradual performance degradation, increased crash rates, and degraded user experience. The key is automation: manual DevTools profiling is useful for diagnosis but must be backed by automated tests that run on every deployment.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.