How Google Chat fixed 30+ memory issues with Chrome DevTools for agents

Vatan Aggarwal
Vatan Aggarwal Software Engineer

Published: Oct 29, 2026

Google Chat is a large single-page application. Users regularly leave the web app open on their browser tab for days or weeks at a time. In long-lived applications, client-side memory health is a primary concern. Uncollected closures and detached DOM trees build up slowly, eventually impacting UI performance, garbage collection pauses, or tab crashes on constrained devices.

To address this, the Google Chat team launched an initiative to uncover and fix memory issues. They used Chrome DevTools for agents, which includes the DevTools MCP server, to turn a difficult manual process into an automated workflow.

The challenge: A needle in a 500 MB haystack

Identifying exactly why a leaked object is kept in memory requires tracing retaining paths through heavily minified code. While recent improvements to the Chrome DevTools Memory panel (for example, advanced edge filtering and object querying) make this easier, doing it manually across a 500 MB snapshot can still be slow and mentally exhausting.

And fixing a leak requires two distinct sets of knowledge:

  • You need to understand how the V8 JavaScript engine handles compiled bytecode.
  • You also need to understand your application's specific state management and component lifecycle.

Bridging these two distinct sets of knowledge manually usually takes days of investigation and collaboration with multiple developers per leak.

The solution: Navigate the heap with an agent

The team realized that while large language models are really good at reasoning over structured data, they need the right tools to process massive files while keeping token consumption reasonable. By connecting Google Antigravity to the open-source Chrome DevTools MCP server with memory tools enabled (--memoryDebugging=true), they gave the agent direct programmatic access to the V8 heap.

Instead of a developer manually expanding retainer trees in the Chrome DevTools Memory panel, the agent queried the snapshot directly. The team used a straightforward prompt to start the process:

Discover memory leaks in the heap snapshot chat.heapsnapshot using Chrome DevTools memory tools.

Using the Memory Debugging Skill, the agent executed a structured workflow using specific MCP tools:

  1. Capture or load the snapshot: Using tools like take_heapsnapshot or reading an existing file.
  2. Analyze the heap structure: It scanned for common leak sources using get_heapsnapshot_details and compare_heapsnapshots.
  3. Trace retaining paths: It used get_heapsnapshot_retaining_paths to walk the dominator tree back to the garbage collection roots.
  4. Generate fixes: It cross-referenced the retaining paths with the application's source code to output highly specific patches.

Technical insights: Three common memory leak patterns

The agent identified over 30 distinct leaks. The following three are the most impactful patterns the agent found, representing the largest memory savings and the most common architectural anti-patterns in modern web applications.

The static error stack trace trap

The agent identified the largest single leak was tied to pre-allocated error objects that permanently retained nearly 20 MB of memory. For example, a single static error constant (EMPTY_KEY_ERROR) retained 14.1 MB on its own.

The problem: In modern JavaScript engines like V8, creating an Error object captures a snapshot of the current call stack to aid in debugging. The massive retained size occurred because of this exact mechanism:

  • The error was a global constant: It was instantiated during the initial module evaluation.
  • The JavaScript engine captured the stack: The active function on the stack at that time was the application's main initialization wrapper (an Immediately Invoked Function Expression, or IIFE).
  • The array trapped the memory: Because the application never garbage collected the error, its internal stack trace array held a permanent reference to the main bundle's initialization function.
// Ineffective pattern: Instantiating Error objects at module evaluation time
export const EMPTY_KEY_ERROR = new Error("Key cannot be empty");

export function validateKey(key: string) {
  if (!key) {
    throw EMPTY_KEY_ERROR;
  }
}

The fix: The team changed the code to create errors dynamically when thrown. If a reusable static error is strictly necessary, explicitly clearing the stack (EMPTY_KEY_ERROR.stack = '';) immediately frees the captured V8 internal frames.

// Recommended: Construct errors dynamically
export function validateKey(key: string) {
  if (!key) {
    throw new Error("Key cannot be empty");
  }
}

// Or clear the stack property if a static instance is necessary:
export const REUSABLE_ERROR = new Error("Static error");
REUSABLE_ERROR.stack = ""; // Releases the captured initialization frame

Scaling global window event listeners

The agent found a memory scaling issue involving visibilitychange event listeners. While this is not a strict "leak" (since they are eventually cleaned up when the component unmounts), it increased memory usage because there was one listener for every message on the screen. The snapshot revealed 1,142 built-in event listeners, with 128 active listeners attached directly to the global window object.

The problem: In JavaScript, event listener callbacks are closures. A closure retains references to its enclosing lexical scope to ensure the variables it needs remain accessible. The agent tracked 126 of these listeners back to a single component: the RelativeTimeController. Each timestamp in the Chat UI (for example, "5 minutes ago") managed its own controller, which registered a private visibility listener to update the time. Because each listener anchored its controller instance to the window root, a dense web of active closures tied up significant memory for the lifetime of the visible chat thread.

// Ineffective pattern: Attaching global window listeners in individual UI controllers
export class RelativeTimeController {
  constructor() {
    this.visibilityListener = () => this.updateTime();
    window.addEventListener("visibilitychange", this.visibilityListener);
  }

  // If dispose() fails to run or unregister, the closure pins the controller
}

The fix: The team centralized global window events. A shared TimerService now registers exactly one visibility listener on the window and broadcasts updates to all active timestamp controllers. This instantly collapsed 126 listeners down to exactly 1.

// Recommended: Centralized event coordination using a shared service
export class VisibilityService {
  private listeners = new Set<() => void>();

  constructor() {
    // Single global listener throttled for the entire application
    window.addEventListener("visibilitychange", () => this.notifyAll());
  }

  subscribe(callback: () => void): () => void {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback); // Unsubscribe handle
  }
}

Third-party SDK and embedded iframe retainers

Detached DOM nodes are elements that have been removed from the document tree but are still referenced by a JavaScript variable. If a single element in a detached DOM tree is retained by a root variable, the entire detached tree cannot be reclaimed.

The problem: When users navigated between chat spaces, the agent found that entire detached DOM trees remained in memory. It traced the retaining path back to an embedded media player within the chat space's UI. When a chat space unmounted, the UI component was removed from the page, but the underlying media player SDK was never explicitly destroyed. The third-party library maintained an internal global dictionary of active iframe IDs, keeping the detached DOM alive.

The fix: Ensure all embedded iframes and third-party widgets tie into component disposal lifecycles. Explicitly calling the .destroy() method on the player clears the entry from the global SDK registry and allows the DOM to be garbage collected.

// Recommended: Explicit teardown of embedded frames and SDK instances
export class MediaPlayerController {
  private player: ThirdPartyPlayer | null = null;

  dispose(): void {
    if (this.player) {
      this.player.destroy(); // Clears entry from global SDK registry
      this.player = null;
    }
  }
}

Additional patterns

While the three patterns mentioned before were the most impactful in terms of raw megabytes saved, the agent also detected and fixed over 27 other leaks. These included:

  • Image array telemetry: Where detached DOM trees were kept alive by custom diagnostic arrays tracking above-the-fold image metrics
  • Framework component lifecycle bugs: Where custom drop-down menus hoisted to the document body bypassed the unmounting of their original parents
  • Implicit arguments object closure trap: Where inner closures unintentionally captured the entire arguments array of outer functions

Impact: Proactive memory management as a workflow

By equipping a coding agent with Chrome DevTools for agents, the Google Chat team automated a highly specialized task. Tracing a complex memory leak dropped from days of manual heap traversal to minutes of automated analysis.

Much like P2ER implemented "Mandatory Empirical Verification" to enforce UI stability, teams can adopt this memory auditing approach as a proactive guardrail. Developers often dismiss memory issues, assuming they only happen in really big or complex applications. However, as this case study shows, common patterns like standard event listeners or static errors can silently accumulate. Integrating tools like Chrome DevTools for agents into your daily development cycle transforms AI from a basic code generator into a reliable quality gate that catches silent regressions before they reach production.