Google Chat がエージェント向け Chrome DevTools で 30 件以上のメモリの問題を修正した方法

Vatan Aggarwal
Vatan Aggarwal Software Engineer

公開日: 2026 年 10 月 29 日

Google Chat は大規模なシングルページ アプリケーションです。ユーザーは、ウェブアプリをブラウザタブで数日から数週間開いたままにすることがよくあります。長期間実行されるアプリケーションでは、クライアントサイドのメモリの状態が主な懸念事項です。未回収のクロージャとデタッチされた DOM ツリーが徐々に蓄積され、最終的には UI のパフォーマンス、ガベージ コレクションの一時停止、制約のあるデバイスでのタブのクラッシュに影響します。

これに対処するため、Google Chat チームはメモリの問題を特定して修正する取り組みを開始しました。DevTools MCP サーバーを含むエージェント向け Chrome DevTools を使用して、困難な手動プロセスを自動ワークフローに変換しました。

課題: 500 MB の干し草の山から針を探す

リークしたオブジェクトがメモリに保持されている理由を正確に特定するには、高度に縮小されたコードを介して保持パスをトレースする必要があります。Chrome DevTools のメモリパネルの最近の改善(高度なエッジ フィルタリングやオブジェクト クエリなど)により、この作業は簡単になりましたが、500 MB のスナップショットを手動で行うのは、依然として時間がかかり、精神的に疲れる作業です。

リークを修正するには、次の 2 つの異なる知識が必要です。

  • V8 JavaScript エンジンがコンパイルされたバイトコードをどのように処理するかを理解する必要があります。
  • また、アプリケーション固有の状態管理とコンポーネントのライフサイクルを理解する必要があります。

この 2 つの異なる知識セットを手動でつなぐには、通常、リークごとに数日間の調査と複数のデベロッパーとのコラボレーションが必要です。

解決策: エージェントを使用してヒープをナビゲートする

チームは、大規模言語モデルは構造化データの推論に非常に優れているものの、トークンの消費を合理的に抑えながら大規模なファイルを処理するには適切なツールが必要であることに気づきました。Google Antigravity を、メモリツールが有効になっているオープンソースの Chrome DevTools MCP サーバー(--memoryDebugging=true)に接続することで、エージェントに V8 ヒープへの直接的なプログラム アクセスを許可しました。

デベロッパーが Chrome DevTools のメモリパネルでリテーナー ツリーを手動で展開する代わりに、エージェントがスナップショットを直接クエリしました。チームは、次のようなシンプルなプロンプトを使用してプロセスを開始しました。

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

メモリ デバッグ スキルを使用して、エージェントは特定の MCP ツールを使用して構造化されたワークフローを実行しました。

  1. スナップショットをキャプチャまたは読み込む: take_heapsnapshot などのツールを使用するか、既存のファイルを読み取ります。
  2. ヒープ構造を分析する: get_heapsnapshot_details と compare_heapsnapshots を使用して、一般的なリークソースをスキャンしました。
  3. パスを保持するトレース: get_heapsnapshot_retaining_paths を使用して、ドミネーター ツリーをガベージ コレクションのルートまで戻ります。
  4. 修正を生成する: 保持パスとアプリケーションのソースコードを相互参照して、非常に具体的なパッチを出力しました。

技術的な分析情報: 3 つの一般的なメモリリーク パターン

エージェントは 30 件以上の漏洩を特定しました。次の 3 つは、エージェントが検出した最も影響の大きいパターンです。これらは、メモリを最も節約できるパターンであり、最新のウェブ アプリケーションで最も一般的なアーキテクチャのアンチパターンです。

静的エラーのスタック トレースのトラップ

エージェントは、最大のリークが、約 20 MB のメモリを永続的に保持する、事前に割り当てられたエラー オブジェクトに関連していることを特定しました。たとえば、1 つの静的エラー定数(EMPTY_KEY_ERROR)だけで 14.1 MB を保持していました。

問題: V8 などの最新の JavaScript エンジンでは、Error オブジェクトを作成すると、デバッグを支援するために現在のコールスタックのスナップショットがキャプチャされます。保持サイズが大きくなったのは、まさにこのメカニズムが原因です。

  • エラーがグローバル定数であった: 最初のモジュール評価時にインスタンス化されました。
  • JavaScript エンジンがスタックをキャプチャした: その時点でスタック上のアクティブな関数は、アプリケーションのメイン初期化ラッパー(即時実行関数式、IIFE)でした。
  • 配列がメモリをトラップした: アプリケーションがエラーをガベージ コレクションしなかったため、内部スタック トレース配列がメインバンドルの初期化関数への永続的な参照を保持していました。
// 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;
  }
}

修正: チームは、エラーがスローされたときに動的にエラーを作成するようにコードを変更しました。再利用可能な静的エラーがどうしても必要な場合は、スタックを明示的にクリア(EMPTY_KEY_ERROR.stack = '';)すると、キャプチャされた V8 内部フレームがすぐに解放されます。

// 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

グローバル ウィンドウ イベント リスナーのスケーリング

エージェントは、visibilitychange イベント リスナーに関連するメモリスケーリングの問題を検出しました。これは厳密な「リーク」ではありませんが(コンポーネントがマウント解除されると最終的にクリーンアップされるため)、画面上のメッセージごとに 1 つのリスナーが存在するため、メモリ使用量が増加しました。スナップショットでは、1,142 個の組み込みイベント リスナーが検出され、128 個のアクティブなリスナーがグローバル ウィンドウ オブジェクトに直接アタッチされていました。

問題: JavaScript では、イベント リスナーのコールバックはクロージャです。クロージャは、囲んでいるレキシカル スコープへの参照を保持し、必要な変数に引き続きアクセスできるようにします。エージェントは、これらのリスナーのうち 126 個を 1 つのコンポーネント(RelativeTimeController)に追跡しました。Chat UI の各タイムスタンプ(「5 分前」など)は、独自のコントローラを管理し、時間を更新するためにプライベートな可視性リスナーを登録していました。各リスナーがコントローラ インスタンスをウィンドウ ルートに固定していたため、アクティブなクロージャの密なウェブが、表示されているチャット スレッドの存続期間中、大量のメモリを占有していました。

// 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
}

修正: チームはグローバル ウィンドウ イベントを一元化しました。共有 TimerService がウィンドウに 1 つの可視性リスナーを登録し、すべてのアクティブなタイムスタンプ コントローラに更新をブロードキャストするようになりました。これにより、126 個のリスナーが 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
  }
}

サードパーティの SDK と埋め込み iframe の保持

デタッチされた DOM ノードとは、ドキュメント ツリーから削除されたものの、JavaScript 変数によって参照されている要素のことです。デタッチされた DOM ツリー内の単一の要素がルート変数によって保持されている場合、デタッチされたツリー全体を再利用することはできません。

問題: ユーザーがチャット スペース間を移動すると、デタッチされた DOM ツリー全体がメモリに残っていることがわかりました。保持パスをたどると、チャット スペースの UI 内の埋め込みメディア プレーヤーにたどり着きました。チャット スペースがアンマウントされると、UI コンポーネントはページから削除されましたが、基盤となるメディア プレーヤー SDK は明示的に破棄されませんでした。サードパーティ ライブラリは、アクティブな iframe ID の内部グローバル ディクショナリを保持し、デタッチされた DOM を有効な状態に保っていました。

修正方法: 埋め込まれた iframe とサードパーティ ウィジェットがすべて、コンポーネントの破棄ライフサイクルに関連付けられていることを確認します。プレーヤーで .destroy() メソッドを明示的に呼び出すと、グローバル SDK レジストリからエントリが消去され、DOM がガベージ コレクションの対象になります。

// 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;
    }
  }
}

その他のパターン

前述の 3 つのパターンは、節約された未加工のメガバイト数という点では最も効果的でしたが、エージェントは他にも 27 件以上のリークを検出して修正しました。たとえば、次のようなものがあります。

  • 画像配列のテレメトリー: スクロールせずに見える範囲の画像指標を追跡するカスタム診断配列によって、デタッチされた DOM ツリーが維持されていた場所
  • フレームワーク コンポーネントのライフサイクルに関するバグ: ドキュメント本文にホイストされたカスタム プルダウン メニューが、元の親のアンマウントをバイパスするバグ。
  • 暗黙的な引数オブジェクトのクロージャ トラップ: 内部クロージャが外部関数の引数配列全体を意図せずキャプチャする

影響: ワークフローとしてのプロアクティブなメモリ管理

Google Chat チームは、コーディング エージェントにエージェント向け Chrome DevTools を搭載することで、高度に専門化されたタスクを自動化しました。複雑なメモリリークのトレースは、数日間の手動ヒープ トラバーサルから数分間の自動分析に短縮されました。

P2ER が UI の安定性を確保するために「必須の実証検証」を実装したのと同様に、チームはこのメモリアーキテクチャのアプローチをプロアクティブなガードレールとして採用できます。デベロッパーは、メモリの問題は非常に大規模なアプリケーションや複雑なアプリケーションでのみ発生すると考え、無視することがよくあります。ただし、このケーススタディで示すように、標準のイベント リスナーや静的エラーなどの一般的なパターンが、気付かないうちに蓄積されることがあります。エージェント向け Chrome DevTools などのツールを日々の開発サイクルに統合することで、AI は基本的なコード生成ツールから、本番環境に到達する前にサイレント リグレッションを検出する信頼性の高い品質ゲートに変わります。