メインコンテンツに移動

UIのメモリリーク:原因・Chrome DevToolsでの検出・修正方法

ウェブアプリケーションを開いた直後は軽快に動作するのに、一覧と詳細を何度も往復する、モーダルを繰り返し開閉する、検索条件を変更する、画像を切り替えると、少しずつ画面が重くなることがあります。スクロールが引っ掛かり、文字入力が遅れ、ファンが回り始め、最終的にはタブが応答しなくなる場合もあります。このような問題は、一回の操作確認や短時間の性能計測では見つけにくく、長時間利用される業務システム、管理画面、チャット、地図、動画編集、画像処理などで深刻になりやすい特徴があります。

JavaScriptは、作成されたオブジェクトが不要になれば、ガベージコレクションによって自動的にメモリを回収します。しかし、自動管理だから開発者がメモリを意識しなくてよいわけではありません。不要になった画面やデータへ、イベントリスナー、タイマー、グローバル変数、購読処理、閉じたウィンドウなどから参照が残っていれば、ブラウザーはそのオブジェクトが今後も使われる可能性があると判断し、回収できません。MDNも、JavaScriptの自動的なメモリ管理は、開発者がメモリ管理を考慮しなくてもよいという誤解を生みやすいと説明しています。

UIのメモリリーク監査では、現在の使用量が大きいかだけでなく、同じ操作を開始前の状態へ戻した後も、メモリ、DOMノード、イベントリスナー、文書、フレームなどが増え続けるかを確認します。Chrome DevToolsは、メモリリーク、過剰なメモリ使用、頻繁なガベージコレクションを異なる問題として扱い、Heap Snapshot、Allocation Timeline、Detached elementsなどの診断手段を提供しています。 本記事では、リークの定義、代表原因、再現手順、DevToolsでの調査、修正、回帰試験、継続監視までを詳しく解説します。

1. UIのメモリリークとは

UIのメモリリークは、単にページが多くのメモリを使う状態ではありません。画面や部品が不要になったにもかかわらず、何らかの参照関係によって関連オブジェクトが保持され、操作の反復とともに回収不能なメモリが積み重なる状態です。

1.1 不要なUIオブジェクトが回収されない状態

UIのメモリリークとは、閉じたモーダル、移動前の画面、削除された一覧行、使い終わった画像、終了した通信処理などが、利用者から見えなくなった後もメモリに保持され続ける問題です。JavaScriptでは、オブジェクトへ到達可能な参照が残っている限り、ガベージコレクターはそのオブジェクトを不要と判断できません。

たとえば、DOMから削除したモーダル要素をグローバル配列が保持していれば、モーダルの子要素、イベント処理、関連データも一緒に残る可能性があります。画面上から消えたことと、メモリから解放されたことは同じではありません。

1.2 操作の反復で増え続けることが特徴

正当なキャッシュや初期化では、最初の操作でメモリが増えても、二回目以降は同程度で安定する場合があります。メモリリークでは、画面を開いて閉じる、経路を往復する、ファイルを選択して解除するなど、同じ操作を繰り返すたびに基準値が段階的に上がります。

一時的な増加だけでリークと断定してはいけません。ガベージコレクションは常に直後に実行されるとは限らないため、操作後に一定時間待ち、必要に応じて診断環境でガベージコレクションを実行し、それでも以前の基準値へ近づかないかを比較します。

1.3 JavaScriptヒープだけの問題ではない

UIが使用するメモリには、JavaScriptオブジェクトだけでなく、DOMノード、画像の復号データ、Canvasの描画領域、ArrayBuffer、WebAssemblyメモリ、iframeやWorkerなどが含まれます。JavaScriptヒープが安定していても、画像やフレームなどブラウザー側の資源が増えている可能性があります。

ChromeのMemoryパネルでは、JavaScriptオブジェクトと関連DOMノードのHeap Snapshot、時間ごとの割当、関数別の割当、JavaScript参照によって保持されたDetached elementsを異なる視点から調査できます。 一つの数字だけで判断せず、症状に合う計測手段を選ぶ必要があります。

1.4 利用者からは徐々に悪化する性能問題に見える

メモリリークが進むと、ガベージコレクターがより多くのオブジェクトを調べる必要が生じ、停止やCPU負荷が増えることがあります。また、DOMノードやイベントリスナーが増えれば、イベント処理やレイアウト計算の対象も拡大します。

Chrome DevToolsの公式文書では、メモリ問題があるページは時間とともに性能が悪化し、頻繁なガベージコレクションによって停止が発生することがあると説明されています。 利用者からは「しばらく使うと重い」「再読み込みすると直る」という報告として現れることが多くなります。

1.5 UIのライフサイクル不整合として考える

リークの多くは、「開始した処理を終了していない」というライフサイクルの不整合です。部品の表示時にイベントリスナーを登録したのに非表示時に解除しない、画面表示時に購読を開始したのに画面離脱時に停止しないといった対称性の欠如が原因になります。

監査では、各部品について、作成、表示、更新、非表示、破棄という段階を整理します。作成時に開始する副作用には、破棄時の終了処理があるかを確認します。

開始時の処理終了時に必要な処理
イベントリスナー登録リスナー解除またはSignalの中止
setInterval()開始clearInterval()
Observer登録disconnect()またはunobserve()
WebSocket接続ハンドラー解除とclose()
Blob URL作成URL.revokeObjectURL()
Worker作成メッセージ解除とterminate()
外部購読開始購読解除関数の実行

2. メモリリークがUIへ与える影響

リークはメモリ使用量の数字だけの問題ではありません。操作応答、描画、端末温度、通信、正確性、アクセシビリティなど、利用者が直接経験する品質へ影響します。

2.1 画面操作が徐々に遅くなる

イベントリスナーや購読が重複すると、一回の操作に対して同じ処理が複数回実行されることがあります。モーダルを十回開閉した後、一回のクリックで十個のハンドラーが実行されれば、処理時間が増えるだけでなく、API要求や状態更新も重複します。

メモリ使用量の増加と操作遅延を同じ時系列で確認すると、単なる描画性能問題か、保持された処理の累積かを区別しやすくなります。クリック一回あたりのハンドラー実行数も記録します。

2.2 ガベージコレクションによる停止が増える

大量の一時オブジェクトが繰り返し作られる場合、リークがなくてもガベージコレクションが頻発します。リークが存在すると生存オブジェクトが増え、回収対象の探索や整理が重くなる可能性があります。

Chrome DevToolsは、タスクマネージャーやPerformance記録でメモリ値が頻繁に上下する状態を、頻繁なガベージコレクションの手掛かりとして紹介しています。 リークと割当過多は別問題ですが、同時に発生することがあります。

2.3 モバイル端末で強制終了につながる

利用可能なメモリが少ない端末では、デスクトップで目立たないリークでも短時間で限界へ達します。OSやブラウザーがタブを破棄したり、ページを再読み込みしたりする場合、利用者が入力していた内容を失う可能性があります。

監査では高性能な開発端末だけでなく、代表的な低~中性能端末で長時間操作を行います。特に画像、動画、地図、長い一覧を扱うUIでは、JavaScriptヒープ以外の資源も監視します。

2.4 重複処理によって機能が誤動作する

リークしたイベント処理や購読は、性能だけでなく機能の正確性を壊します。一回の送信で複数回APIを呼ぶ、古い画面の購読が新しい状態を上書きする、閉じたモーダルのキーボード処理が残って現在の画面を妨げるといった問題が起こります。

メモリリークの修正を性能改善としてだけ扱わず、重複送信、古い状態更新、二重通知などの機能不具合も確認します。修正後はメモリ値と操作結果の両方を再試験します。

2.5 支援技術向けの状態が残ることがある

画面から取り除いた対話部品の処理や非表示要素が残り、フォーカス管理やキーボードショートカットへ影響する場合があります。閉じたダイアログ用のkeydown処理が残れば、新しい画面でEscapeキーが意図しない操作を起こす可能性があります。

UIの破棄では、視覚要素を消すだけでなく、フォーカス処理、グローバルキーボード処理、ライブ通知、Observerを終了します。アクセシビリティ試験も、部品を一度だけ使う状態ではなく、開閉を反復した後に実施します。

3. 類似するメモリ問題との違い

メモリ使用量が大きい状態をすべてリークと呼ぶと、原因と修正方法を誤ります。メモリリーク、メモリ肥大、割当過多、DOM増加、意図的キャッシュを区別して監査します。

3.1 メモリ肥大との違い

メモリ肥大は、現在の機能を実行するために必要以上に大きなデータや資源を保持している状態です。すべてが到達可能で意図的に保持されているため、技術的にはリークでない場合があります。

たとえば、一万件の検索結果をすべて保持する設計は、画面仕様どおりでも大きなメモリを使います。リークなら操作ごとに不要な以前の結果が残り、肥大なら一回の状態自体が大きすぎます。Chrome DevToolsも、リーク、肥大、頻繁なガベージコレクションを異なる問題として扱っています。

比較項目メモリリークメモリ肥大
主な状態不要資源が回収されない必要状態が大きすぎる
反復操作基準値が増え続ける一定の高い値で安定しやすい
主な修正不要参照と副作用を解除データ量・表現・保持方式を縮小
代表例閉じた画面を配列が保持全件データを常時保持
検証開閉前後の差分一状態の内訳

3.2 割当過多との違い

割当過多では、短時間に多数のオブジェクトを作って回収する処理が繰り返されます。最終的な基準値は戻るものの、頻繁な回収によって停止やCPU負荷が発生します。

リークの調査だけを行うと、生存していない一時オブジェクトを見落とします。Allocation samplingやPerformance記録を使い、どの関数が多くの割当を発生させているかを確認します。ChromeのMemoryパネルは、低い負荷で関数別の割当を概算するAllocation samplingも提供しています。

比較項目メモリリーク割当過多
オブジェクト不要でも生存する短時間で作成・回収される
メモリ推移底値が上昇する上下を頻繁に繰り返す
利用者影響長時間利用で悪化短い操作でも停止しやすい
主な道具Heap Snapshot、Allocation TimelineAllocation sampling、Performance
修正保持参照を除く再利用・生成量削減

3.3 DOMノード増加との違い

DOMノードが増えていても、無限スクロールや履歴表示など仕様上必要な場合があります。逆にDOMノード数が戻っていても、JavaScriptオブジェクトや画像資源が漏れている可能性があります。

監査ではDOMノード数を有力な兆候として使いますが、単独で合否を決めません。Performance MonitorではDOM NodesやJavaScript event listenersなどをリアルタイムで監視でき、操作反復中の増加傾向を確認できます。

比較項目DOMノード増加メモリリーク
測定対象文書内または保持されたノードメモリ資源全般
正当な増加長い一覧などであり得る不要資源の保持は問題
ノード削除後数が戻る場合があるJS参照で保持される場合がある
主な確認DOM Nodes、Detached elementsヒープ、資源、参照経路
関係リークの兆候になり得るDOM以外でも発生する

3.4 意図的キャッシュとの違い

キャッシュは再計算や再取得を減らすため、データを意図的に保持します。容量上限、失効条件、削除方法があり、同じキーのデータを再利用するなら、メモリ使用量は一定範囲で安定します。

リークに見えるキャッシュでも、利用者価値と上限が明確なら問題ではない場合があります。一方、閲覧したすべてのIDを無制限に保持するキャッシュは、設計上の肥大または実質的なリークになります。

比較項目意図的キャッシュメモリリーク
保持理由再利用が明確不要だが参照が残る
上限件数・容量・期限がある通常は上限がない
削除規則失効・追い出しがある解除処理がない
監視命中率・容量を測る反復後の生存を測る
判断費用対効果で評価不要参照を修正

3.5 一時的な高使用量との違い

ファイルの解析、画像編集、報告書生成などでは、処理中だけ多くのメモリを必要とすることがあります。処理終了後に資源が解放され、基準値へ近づくならリークとは限りません。

大きな処理の直後だけを測るのではなく、処理完了、画面離脱、一定待機後の三時点を比較します。ピーク値が端末限界を超える場合は、リークでなくても分割処理や流式処理が必要です。

比較項目一時的な高使用量メモリリーク
発生期間処理中に限定処理後も残る
基準値終了後に戻る反復ごとに上がる
主な対策ピーク削減・分割参照解除・破棄
検証時点処理前・中・後複数反復後
危険一時的な上限超過長時間利用で累積

4. JavaScriptのメモリ管理と参照関係

メモリリークを調査するには、オブジェクトの大きさだけでなく、なぜそのオブジェクトが生存しているかを理解する必要があります。ヒープ上では、小さな参照が大きなUI部分を保持する場合があります。

4.1 到達可能性によって回収が決まる

JavaScriptのガベージコレクションでは、グローバルオブジェクト、実行中の関数、登録された処理などの根から参照をたどり、到達可能なオブジェクトを生存させます。不要と思っているオブジェクトでも、根から参照経路が残っていれば回収されません。

監査では、漏れているオブジェクトそのものより、Retainer、つまり保持している参照元を確認します。原因は、対象オブジェクトから遠いグローバル配列、イベント登録、キャッシュであることがあります。

4.2 クロージャーが大きな状態を保持する

関数は外側の変数を参照できるため、イベント処理やタイマーのクロージャーが画面全体の状態を保持する場合があります。関数内で一つの値しか使っていないように見えても、構造によっては周辺状態との参照が残ります。

破棄後も動くコールバックへ、大きな画面状態やDOMノードを直接渡さないようにします。必要な識別子や小さな値だけを取り出し、処理の所有期間を明確にします。

4.3 グローバル変数は長期間の保持元になる

windowや長寿命の単一オブジェクトへ保存した値は、ページを閉じるまで生存しやすくなります。診断目的の履歴、デバッグ配列、一時的な部品参照をグローバルへ追加し続けると、画面破棄後も残ります。

ChromeのHeap Snapshotでは、Windowなどの根から対象オブジェクトまでのRetaining pathを確認できます。スナップショットには根からの最短参照数を示すDistanceなどの情報もあります。

4.4 コンソール操作が対象を保持する場合がある

DevToolsのコンソールでオブジェクトを評価したり、結果を変数へ保存したりすると、調査中のオブジェクトがコンソールによって保持される場合があります。リークを調べる操作自体が回収を妨げることがあります。

ChromeのHeap Snapshot文書も、DevToolsコンソールで評価または操作されたためにメモリへ保持されるオブジェクトを区別しています。 比較測定では、不要なコンソール参照を消し、同じ調査条件を使用します。

4.5 WeakMapやWeakRefを安易な修正にしない

弱い参照は、キーや対象の生存を強制しない用途に利用できますが、イベントリスナー、タイマー、外部購読、ファイル資源を自動的に終了するものではありません。ライフサイクルの設計不足をWeakRefだけで隠すと、終了時期が不明確になります。

所有者が明確な資源には、明示的な解除処理を用意します。弱い参照は補助的に使用し、正しさがガベージコレクションの実行時期へ依存しない構造にします。

5. Detached DOMによるメモリリーク

Detached DOMは、文書ツリーから取り除かれたものの、JavaScript参照によって保持されているDOMノードです。UIの開閉や画面遷移を繰り返すアプリケーションで代表的なリーク原因となります。

5.1 Detached DOMの意味

DOMノードは、ページのDOMツリーとJavaScriptの両方から参照されなくなれば回収できます。DOMツリーから削除されても、JavaScriptが参照している状態はDetachedと呼ばれ、回収されません。Chrome DevToolsは、Detached DOMツリーを一般的なメモリリーク原因として説明しています。

削除された一つの親ノードが保持されると、その子孫、属性、イベント関連オブジェクトもまとめて残る可能性があります。画面上では一件の参照でも、保持サイズは大きくなる場合があります。

5.2 グローバル配列による保持

以前表示した要素を履歴やデバッグ目的で配列へ追加し、削除しない実装は典型例です。DOMから要素を削除しても、配列が参照している限り回収されません。

履歴に必要なのが表示内容なら、DOMノード全体ではなく、文字列や識別子など最小限のデータを保存します。診断用コードも本番へ残さず、容量上限を設定します。

5.3 親ノード参照による子孫の保持

直接保持しているノードが小さくても、親子関係によって大きな部分木を保持することがあります。対象要素のRetained sizeがShallow sizeより大きい場合、参照解除によって間接的に多くのメモリを解放できる可能性があります。

Heap Snapshotでは、DOMとJavaScript間の参照関係を確認し、忘れられたDetached DOM部分木を発見できます。 Constructor名だけでなく、Retainersをたどって保持元を特定します。

5.4 テンプレート・ポータル・オーバーレイを確認する

モーダル、ツールチップ、メニューなどを文書末尾の専用領域へ移す構成では、元画面の破棄とオーバーレイの破棄が別管理になる場合があります。元画面を移動してもオーバーレイ要素や参照が残り続ける問題に注意します。

部品の閉鎖処理と、経路離脱時の強制破棄を両方用意します。利用者が通常の閉じる操作を行わず、別ページへ移動した場合も後処理が実行されるか確認します。

5.5 Detached elementsプロファイルで確認する

ChromeのMemoryパネルには、JavaScript参照によって保持されたDetached elementsを表示するプロファイルがあります。対象HTMLノードと件数を確認できるため、DOMリークの入口を見つけやすくなります。

検出されたDetached要素がすべて不具合とは限りません。再利用予定の要素や開発ツールが保持する要素もあるため、画面破棄後も必要か、反復ごとに増えるかを確認します。

6. イベントリスナー・タイマー・Observerを監査する

イベントリスナーやタイマーは長寿命の対象へ登録されることが多く、コールバックを通じて破棄済みUIを保持します。登録場所だけでなく、登録回数と解除経路を確認します。

6.1 グローバルイベントリスナーを解除する

windowdocumentbodyなどへ登録したリスナーは、子部品をDOMから削除しても自動的には部品の寿命へ合わせて解除されません。リスナー関数が部品状態を参照していれば、状態全体が残ります。

removeEventListener()で解除するには、イベント種類、関数参照、関連するオプションが一致する必要があります。MDNは、登録済みリスナーを同じ関数参照によって解除する方法に加え、AbortSignaladdEventListener()へ渡し、コントローラーのabort()で解除する方法を説明しています。

6.2 匿名関数の重複登録を避ける

画面表示のたびに新しい匿名関数を登録すると、同じ処理内容でも異なる関数オブジェクトとして扱われます。後から同じ記述の匿名関数を渡しても、以前のリスナーを解除できません。

登録した関数参照を保持するか、画面単位のAbortControllerを使用します。登録処理が一度だけ実行される前提にせず、再表示や開発時の再実行でも安全な構造にします。

6.3 タイマーを破棄する

setInterval()は、明示的に停止されるまで関数を繰り返し呼び出します。コールバックが画面状態を参照していれば、画面を離れても状態を保持します。setTimeout()も長い待機時間や再帰的な再登録によって同様の問題を起こします。

タイマーIDを部品の寿命へ関連付け、破棄時にclearInterval()またはclearTimeout()を呼びます。画面非表示中も処理が必要かを検討し、不要なら可視状態に応じて停止します。

6.4 Observerの解除を確認する

MutationObserver、ResizeObserver、IntersectionObserverなどは、対象要素の変化を継続的に監視します。部品破棄後もObserverが対象やコールバックを保持すると、ノードと状態が残ります。

作成した所有者が、破棄時にdisconnect()またはunobserve()を実行します。監視対象だけを削除して終了したと考えず、Observer自体の寿命を管理します。

6.5 登録数をリアルタイムで監視する

同じ開閉操作を繰り返しながら、Performance MonitorでDOM NodesとJS event listenersを観察します。操作を元へ戻した後もリスナー数が段階的に増えるなら、解除漏れの可能性があります。

Chrome DevToolsは、イベントリスナー数が高い場合、コードを見直して数を減らすことがメモリ解放に役立つ可能性を説明しています。 ただし、委任イベントなど正当な長寿命リスナーもあるため、数だけでなく増加経路を調べます。

部品単位で副作用を終了する例

class PanelController {
 #abortController = new AbortController();
 #intervalId = null;
 #observer = null;

 constructor(rootElement) {
   this.rootElement = rootElement;

   window.addEventListener(
     "resize",
     this.handleResize,
     { signal: this.#abortController.signal },
   );

   this.#intervalId = window.setInterval(
     () => this.refresh(),
     30_000,
   );

   this.#observer = new ResizeObserver(() => {
     this.updateLayout();
   });

   this.#observer.observe(rootElement);
 }

 handleResize = () => {
   this.updateLayout();
 };

 refresh() {
   // 必要な更新処理
 }

 updateLayout() {
   // 寸法に応じた処理
 }

 destroy() {
   this.#abortController.abort();

   if (this.#intervalId !== null) {
     window.clearInterval(this.#intervalId);
     this.#intervalId = null;
   }

   this.#observer?.disconnect();
   this.#observer = null;
   this.rootElement = null;
 }
}
 

7. 通信・購読・Workerのメモリリーク

UIは、HTTP要求、WebSocket、イベント配信、Workerなど、画面外の処理と接続します。画面を離れても非同期処理が継続すると、古い状態への参照と重複更新が残ります。

7.1 未完了のFetchを中止する

画面表示時にデータ取得を開始し、完了前に別画面へ移動した場合、応答処理が古い画面状態を参照し続ける可能性があります。応答後に不要なデータを状態管理へ追加する問題も起こります。

AbortControllerのSignalを通信処理へ渡し、画面破棄時にabort()を実行します。MDNは、AbortControllerが非同期処理へ中止を通知する仕組みを提供していると説明しています。

7.2 WebSocketの接続と処理を終了する

チャット、通知、リアルタイム更新では、画面を開くたびにWebSocketやメッセージ処理を追加すると、古い画面も通知を受け続けます。接続を共有する場合でも、画面ごとの購読解除が必要です。

部品が接続自体を所有するならclose()し、共有接続ならその部品のハンドラーだけを解除します。接続所有者と購読所有者を分けて設計します。

7.3 状態管理・イベントバスの購読を解除する

グローバルストア、イベントバス、メッセージ機構へ購読したコールバックは、画面がなくなってもストア側から参照され続ける可能性があります。購読関数が返す解除関数を破棄処理で実行します。

購読開始を複数箇所へ分散させず、一つのライフサイクル境界で管理します。開発環境で画面の表示と破棄を繰り返し、購読者数が元へ戻るかを試験します。

7.4 Workerを終了する

Web Workerは独立した実行環境とメモリを持ちます。画面離脱後もWorkerが動き続ければ、メッセージ処理、ArrayBuffer、計算状態を保持します。

画面専用Workerは破棄時にterminate()します。共有Workerの場合は利用者数を管理し、最後の利用者が離れたときの終了条件を決めます。メッセージリスナーも解除します。

7.5 非同期結果による古い状態更新を防ぐ

通信自体が回収されても、古い要求の結果が新しい画面状態へ書き込まれると、大きなデータがキャッシュやストアへ残る場合があります。要求識別子、画面世代、AbortSignalを使い、不要になった結果を適用しないようにします。

「画面が破棄されたら状態更新を無視する」というフラグだけでは、通信や外部購読自体は継続します。可能な処理は中止し、中止できないものは結果の適用を拒否する二段階で管理します。

8. 画像・Blob・Canvas・iframeを監査する

画像や文書表示を扱うUIでは、JavaScriptヒープよりも大きなブラウザー資源が保持されることがあります。プレビューを切り替える、ファイルを再選択する操作を重点的に確認します。

8.1 Blob URLを解放する

URL.createObjectURL()で作成したBlob URLは、対応するURLが有効な間、基になるBlobを保持します。不要になったときにURL.revokeObjectURL()を呼び、ブラウザーへ解放可能であることを伝える必要があります。

長寿命アプリで画像プレビューを差し替える場合、以前のURLを新しいURLへ置き換える前に解放します。画像をDOMから削除するだけでは、Blob URLが残る可能性があります。MDNも、長寿命アプリでは画像がDOMから削除された時点など、不要になったときにURLを解放するよう案内しています。

8.2 Canvasの大きな描画領域を確認する

CanvasはCSS上の表示サイズより大きな内部ピクセル領域を持つ場合があります。高いdevice pixel ratioへ合わせた大きなCanvasを複数保持すると、メモリ使用量が大きくなります。

非表示Canvas、以前の編集履歴、オフスクリーン描画面を保持し続けていないかを確認します。破棄時には参照を解除し、必要に応じて寸法を縮小して大きな描画領域を手放します。

8.3 ImageBitmapや動画資源を終了する

画像処理や動画では、復号済みのフレームやビットマップがJavaScriptオブジェクト以上のメモリを使用する場合があります。明示的な終了APIを持つ資源は、使用後に終了します。

再生を停止しただけで、ソース、イベント、バッファーがすべて解放されるとは限りません。要素、メディア処理、外部ライブラリーの所有関係を確認します。

8.4 閉じたポップアップへの参照を消す

window.open()が返すWindowオブジェクトを親ページが保持していると、ポップアップを閉じた後もその文書やJavaScriptオブジェクトが保持される場合があります。iframeを削除した後の参照も同様です。

web.devは、閉じたポップアップや削除されたiframeへの参照が残る状態をDetached windowの代表例として説明しています。 closed状態の確認、参照のnull化、イベント解除を行います。

8.5 iframeの遷移とイベント解除を確認する

iframe内文書が遷移すると、以前のWindowやDocumentへの参照が残る場合があります。親ページからiframe内部へ登録したイベント処理が、新しい文書では解除されず、古い文書を保持することがあります。

iframeのloadや破棄時に以前の参照を解放します。異なる生成元を扱う場合は、直接オブジェクト参照を長期間保持せず、必要最小限のメッセージ通信を使用します。

画像プレビューのBlob URLを管理する例

class ImagePreview {
 #objectUrl = null;

 constructor(imageElement) {
   this.imageElement = imageElement;
 }

 show(file) {
   this.clear();

   this.#objectUrl = URL.createObjectURL(file);
   this.imageElement.src = this.#objectUrl;
 }

 clear() {
   this.imageElement.removeAttribute("src");

   if (this.#objectUrl !== null) {
     URL.revokeObjectURL(this.#objectUrl);
     this.#objectUrl = null;
   }
 }

 destroy() {
   this.clear();
   this.imageElement = null;
 }
}
 

9. SPAとUIフレームワークのライフサイクルを監査する

SPAではページ全体を再読み込みせず、画面部品を繰り返し作成・破棄します。そのため、部品のライフサイクルと外部処理の寿命が一致していないと、リークが長時間蓄積します。

9.1 経路遷移時の破棄を確認する

画面内の閉じるボタンでは正常に後処理が実行されても、ブラウザーの戻る操作、別リンク、ログアウトによる経路遷移では処理されない場合があります。監査では、すべての離脱経路を確認します。

一覧から詳細、詳細から一覧という代表経路を多数回繰り返し、DOMノード、購読、タイマー、ヒープの基準値を測ります。経路単位の再現手順を用意します。

9.2 ReactのEffectにはcleanupを実装する

ReactのEffectで接続、イベント登録、購読などを開始した場合、cleanup関数で開始した処理を停止または取り消すことが基本です。Reactの公式文書は、Effectが行ったことを停止または元へ戻し、setup、cleanup、setupの順で実行されても利用者が違いを認識しない構造を推奨しています。

依存値が変わるときにも以前のEffectのcleanupが必要です。アンマウント時だけを考えるのではなく、購読対象の切替時に古い対象から解除されるかを確認します。

9.3 Strict Modeの再実行を問題発見へ使う

ReactのStrict Modeは、開発環境で一部の処理やref callbackを追加実行し、cleanup不足などの問題を早く発見できるようにします。公式文書には、ref callbackが追加した項目をcleanupで除かなかったため、一覧が増え続けてメモリリークとなる例があります。

開発時に処理が二回動くことだけを理由にStrict Modeを外すのではなく、setupとcleanupが対称であるかを修正します。ただし、本番環境のメモリ監視と反復試験も別途必要です。

9.4 Keep-aliveや画面キャッシュを区別する

フレームワークやルーターによっては、戻ったときの状態を維持するため、画面部品を破棄せず休止させる機能があります。この場合、メモリが残ることは設計上の意図ですが、画面数に上限がなければ肥大します。

休止中にタイマーや通信を続ける必要があるかを確認し、非表示時の停止処理を用意します。破棄、休止、再開の三状態を分けます。

9.5 外部ライブラリーの破棄APIを呼ぶ

グラフ、地図、エディター、表計算部品などは、内部でイベントリスナー、Worker、Canvas、キャッシュを作成します。DOM要素を消すだけでは内部資源が残る場合があります。

ライブラリーが提供するdestroy()dispose()remove()などを部品の破棄処理で呼びます。版更新によって破棄方法が変わる可能性があるため、採用版の公式文書と回帰試験を管理します。

React Effectで処理を対称に終了する例

import { useEffect, useState } from "react";

export function LiveStatus({ endpoint }) {
 const [status, setStatus] = useState("connecting");

 useEffect(() => {
   const controller = new AbortController();
   const socket = new WebSocket(endpoint);

   const handleOpen = () => setStatus("connected");
   const handleClose = () => setStatus("disconnected");

   socket.addEventListener("open", handleOpen, {
     signal: controller.signal,
   });

   socket.addEventListener("close", handleClose, {
     signal: controller.signal,
   });

   return () => {
     controller.abort();

     if (
       socket.readyState === WebSocket.OPEN ||
       socket.readyState === WebSocket.CONNECTING
     ) {
       socket.close();
     }
   };
 }, [endpoint]);

 return <p>状態: {status}</p>;
}
 

10. 状態管理・キャッシュ・コレクションを監査する

UI部品を正しく破棄していても、グローバルストア、検索履歴、キャッシュ、ログ配列へデータを追加し続ければ、メモリ使用量は増加します。長寿命データ構造の成長規則を確認します。

10.1 グローバルストアへ画面固有データを残さない

一画面でしか使わないフォーム状態やDOM参照をグローバルストアへ保存すると、画面離脱後も残りやすくなります。共有が必要な状態と、部品内で完結する状態を分けます。

離脱時に不要状態を削除するか、画面単位のストアを破棄します。単に初期値へ上書きする場合も、以前の大きなオブジェクトへの参照が残っていないか確認します。

10.2 配列・Mapの無制限成長を防ぐ

診断ログ、閲覧履歴、通知、検索結果をpush()set()で追加し続ける実装は、長時間利用で肥大します。キーが利用者操作ごとに変わるMapも、実質的に無制限の保存領域になります。

件数、容量、期間の上限を設定し、古い項目を削除します。業務上すべての履歴が必要なら、ブラウザー内ではなく永続化先へ移し、UIには必要範囲だけを保持します。

10.3 キャッシュの失効と追い出しを確認する

キャッシュには最大件数、時間期限、容量制限などの追い出し規則が必要です。検索条件や利用者IDを含むキーが増え続ける場合、再利用率が低いのにメモリだけが増える可能性があります。

キャッシュの現在件数、容量、命中率を監視します。命中しない古い項目を保持し続けていないかを確認します。

10.4 履歴機能のスナップショットを縮小する

元に戻す、やり直す、版管理のために画面状態全体を複製すると、大きなデータが履歴数だけ増えます。画像や文書編集では特に影響が大きくなります。

差分、操作命令、構造共有などを使い、完全複製を減らします。保持する履歴数にも上限を設定し、利用者へ現在の保存範囲を説明します。

10.5 開発用ログや計測データを確認する

開発中に状態を配列へ保存し、コンソールや診断画面で参照する仕組みが本番へ残る場合があります。エラー情報へ大きな要求内容、DOM、画像を含めると、ログ一件が多くの資源を保持します。

計測には必要最小限の値だけを保存し、個人情報と大きなオブジェクトを除外します。診断バッファーは固定長にします。

件数上限のある簡易キャッシュ

class BoundedCache {
 constructor(maxEntries = 100) {
   if (!Number.isInteger(maxEntries) || maxEntries < 1) {
     throw new TypeError("maxEntriesは1以上の整数です");
   }

   this.maxEntries = maxEntries;
   this.values = new Map();
 }

 get(key) {
   if (!this.values.has(key)) {
     return undefined;
   }

   const value = this.values.get(key);

   // 最近使用した項目を末尾へ移動
   this.values.delete(key);
   this.values.set(key, value);

   return value;
 }

 set(key, value) {
   this.values.delete(key);
   this.values.set(key, value);

   while (this.values.size > this.maxEntries) {
     const oldestKey = this.values.keys().next().value;
     this.values.delete(oldestKey);
   }
 }

 clear() {
   this.values.clear();
 }
}
 

11. Chrome DevToolsでメモリリークを検出する

DevToolsには複数のメモリ診断機能があります。対象操作、疑う資源、再現時間に応じて、Task Manager、Performance Monitor、Heap Snapshot、Allocation Timelineを使い分けます。

11.1 Task Managerで全体傾向を見る

Chrome Task Managerでは、タブや処理ごとのメモリ使用量を比較できます。操作前後の大きな傾向を確認し、長時間利用で増え続けるページを特定する入口になります。

Chromeの公式メモリ診断手順でも、最初にTask Managerでページの使用量を把握し、次に詳細なツールで原因を調べる流れが示されています。 Task Managerの数字だけで保持元を特定することはできません。

11.2 Performance Monitorで反復中の増加を見る

Performance Monitorを開き、JS heap size、DOM Nodes、JS event listeners、Documentsなどを表示します。モーダル開閉や経路往復を繰り返し、操作を元へ戻した後も数が増え続けるかを観察します。

単調な増加だけでなく、一定範囲へ戻るかを見ます。DOM Nodesだけが増える、リスナーだけが増えるなど、次に調べる領域を絞ります。

11.3 Heap Snapshotを比較する

安定した初期状態でスナップショットを取得し、対象操作を複数回実行して元の画面へ戻り、必要な待機後に二枚目を取得します。Comparison表示で増えたConstructor、個数、サイズを確認します。

Heap Snapshotは、JavaScriptオブジェクトと関連DOMノードの分布、Retainers、Detached DOMを調査できます。 増えたオブジェクトから保持経路をたどり、Window、リスナー、配列、ストアなどの根本原因へ到達します。

11.4 Allocation Timelineで生存オブジェクトを追う

Allocation instrumentation on timelineを開始し、問題操作を実行して停止します。記録中に作成され、終了時点でも生存しているオブジェクトを時間帯別に絞り込みます。

Chromeの公式文書では、青いバーが記録終了時にも生存している割当を示し、対象を選択するとRetaining pathを確認できると説明されています。 操作のどの瞬間に漏れるオブジェクトが作られたかを特定するのに適しています。

11.5 Allocation samplingで関数別の割当を見る

長時間の操作や、割当量が多い関数を低い計測負荷で調べたい場合はAllocation samplingを使用します。関数の呼出し階層別に、どこで多くのメモリが割り当てられているかを概算できます。

Chrome DevToolsは、標本方式によって関数別割当を記録し、Heavy Bottom Upなどの表示で大きな割当元を確認できると説明しています。 生存を正確に追う用途では、Heap SnapshotやAllocation Timelineと組み合わせます。

12. 再現可能なメモリリーク監査手順

メモリリークは操作順序や待機時間によって見え方が変わるため、監査手順を固定します。再現操作、反復回数、測定時点を明確にし、修正前後を比較できる状態にします。

12.1 安定した開始状態を作る

対象ページを新しいタブまたは専用プロファイルで開き、必要な初期データの読み込みが完了するまで待ちます。広告、実験、外部通知など測定ごとに内容が変わる要素を記録します。

DevToolsで対象を選択したこと自体が参照を作る可能性もあるため、比較時は同じ手順を使用します。不要なコンソール変数を作らないようにします。

12.2 利用者の反復経路を定義する

「モーダルを開く」だけでなく、「一覧を開く、三行目を選ぶ、詳細を閉じる、一覧へ戻る」のように操作を具体化します。通常の閉じる操作、戻る操作、別経路への移動など、破棄方法の違いも分けます。

一回では差が小さい場合、十回、二十回と反復します。ただし、実利用では起こらない極端な操作だけで優先度を決めず、利用頻度を記録します。

12.3 操作前後で同じ時点を比較する

初期画面の読み込み直後と、反復操作の途中を比較すると、正当な新規データが混在します。操作を完了し、最初と同じ画面・条件へ戻した時点で比較します。

通信中、アニメーション中、タイマー直前などの状態差を避けます。必要に応じて数秒待ち、非同期の終了を確認します。

12.4 増加対象と保持経路を分けて記録する

「Arrayが増えた」という結果だけでは修正できません。どの種類のArrayか、いつ作られ、何から保持され、どのUI状態と関係するかを記録します。

Constructor、Retained size、Retainer、生成スタック、対象操作を報告書へ含めます。同じ原因で複数種類のオブジェクトが残る場合は、一つの根本問題としてまとめます。

12.5 修正後に機能とメモリを再試験する

リスナーや購読を解除した結果、再表示後に必要な処理まで動かなくなる場合があります。メモリ値が戻ることだけでなく、開く、閉じる、再度開く、データ更新、エラー処理を確認します。

修正前と同じ反復回数と計測条件を使用します。基準値が完全に一致しなくても、増加傾向が収束するかを確認します。

監査段階実施内容記録する証拠
基準取得初期画面を安定させるヒープ、DOM、リスナー数
操作定義済み経路を反復回数、時刻、入力条件
復帰初期と同じ状態へ戻る画面、通信、タイマー状態
分析増加とRetainerを調査Snapshot、Allocation記録
修正確認同条件で再試験前後差、機能結果

13. リークを防ぐ実装パターン

個別の解除漏れを修正するだけでは、別の部品で同じ問題が再発します。副作用の所有者、寿命、終了方法を共通パターンとして実装します。

13.1 setupとcleanupを同じ場所へ置く

登録処理と解除処理が異なるファイルや担当へ分散すると、変更時に一方だけが更新されやすくなります。同じ関数、Effect、クラス内で開始と終了を対応させます。

Reactの公式指針でも、Effectが行う同期処理には対応するcleanupを用意し、再実行時にも正しい状態を維持する考え方が示されています。 フレームワークに関係なく、同じ原則を適用できます。

13.2 AbortControllerで複数処理をまとめる

一つのUI部品で複数イベントや通信を開始する場合、個別IDを多数管理するより、部品単位のAbortControllerを使うと終了境界を明確にできます。addEventListener()へSignalを渡したリスナーは、abort()によってまとめて解除できます。

タイマー、Observer、WebSocketなど、AbortSignalへ直接対応しない資源は別途終了します。すべてをabort()だけで解放できると誤解しないようにします。

13.3 所有者を一つにする

接続や資源を複数部品が共同所有すると、誰が終了すべきか不明になります。作成者が終了責任を持つか、共有サービスが参照数を管理するなど、所有規則を決めます。

子部品が親の資源を勝手に終了しないようにします。借用する側には購読解除だけを提供し、共有接続の終了は所有者へ限定します。

13.4 破棄処理を何度呼んでも安全にする

画面離脱とエラー処理が同時に発生し、cleanupが複数回呼ばれる場合があります。二回目の解除で例外が起きないようにし、破棄済み状態を管理します。

タイマーIDや参照を解除後にnullへし、既に終了している資源への処理を避けます。冪等なcleanupは、複雑な遷移と開発時の再実行へ対応しやすくなります。

13.5 破棄後の利用を検出する

開発環境では、破棄済み部品へ状態更新、イベント、通信結果が到着した場合に警告を出すと、ライフサイクル不整合を早く発見できます。

警告だけで処理を放置せず、通信中止や購読解除を行います。破棄後の操作を静かに無視する実装は、メモリリークと不要な処理を隠す場合があります。

共通の破棄管理クラス

class CleanupGroup {
 #cleanups = [];
 #disposed = false;

 add(cleanup) {
   if (typeof cleanup !== "function") {
     throw new TypeError("cleanupは関数で指定してください");
   }

   if (this.#disposed) {
     cleanup();
     return;
   }

   this.#cleanups.push(cleanup);
 }

 dispose() {
   if (this.#disposed) {
     return;
   }

   this.#disposed = true;

   const errors = [];

   for (const cleanup of this.#cleanups.reverse()) {
     try {
       cleanup();
     } catch (error) {
       errors.push(error);
     }
   }

   this.#cleanups = [];

   if (errors.length > 0) {
     throw new AggregateError(
       errors,
       "一部の終了処理に失敗しました",
     );
   }
 }
}

function mountPanel(root) {
 const cleanupGroup = new CleanupGroup();
 const controller = new AbortController();

 cleanupGroup.add(() => controller.abort());

 window.addEventListener(
   "resize",
   () => updatePanel(root),
   { signal: controller.signal },
 );

 const intervalId = window.setInterval(
   () => refreshPanel(root),
   10_000,
 );

 cleanupGroup.add(() => {
   window.clearInterval(intervalId);
 });

 return () => cleanupGroup.dispose();
}
 

14. 自動試験・性能予算・継続監視

メモリリークはコードレビューだけでは見つけにくいため、代表的な反復経路を自動試験へ組み込みます。ただし、ガベージコレクションの時期やブラウザー差を考慮し、単一の厳密値だけで判定しないようにします。

14.1 反復経路を自動操作する

画面遷移、モーダル開閉、画像交換、編集開始と取消など、過去にリークが発生した経路を自動化します。操作前後で同じ画面状態へ戻し、ヒープ、DOM、リスナーの増加傾向を取得します。

一回の差ではなく、複数反復の傾きを見ます。最初の数回はキャッシュや初期化で増える可能性があるため、安定区間を別に評価します。

14.2 件数と容量の両方を監視する

DOMノードが一件だけ増えていても大きな部分木を保持している場合があります。反対に、小さなオブジェクトが多数増えても総容量は限定的な場合があります。

オブジェクト数、Shallow size、Retained size、DOM Nodes、event listenersを組み合わせます。重要な資源は個別のアプリケーション指標として件数を公開します。

14.3 絶対値より増加率と収束を見る

ブラウザー版、端末、ビルドによって基準メモリは変わります。固定した「百MB以下」だけでは、軽い端末での危険や反復リークを判断できません。

初期値からの増加、操作一回あたりの増加、一定反復後の収束を評価します。絶対上限は端末安定性のため別に設定します。

14.4 長時間試験を定期実行する

短い変更確認では見つからない問題へ対応するため、代表操作を数十分から数時間実行する試験を定期的に行います。リアルタイム更新、チャット、監視画面などは特に有効です。

すべての変更で長時間試験を行う必要はありません。短い回帰試験を日常的に実行し、長時間試験を定期または重要変更時に実施します。

14.5 実利用の症状と結び付ける

本番でページ滞在時間が長くなるほど操作遅延、強制終了、再読み込みが増える場合、メモリ問題の可能性があります。ただし、利用者の端末メモリを精密に取得することには制限とプライバシー上の注意があります。

ブラウザー内の詳細オブジェクトを外部送信するのではなく、長時間セッションのエラー、操作時間、再読み込みなど必要最小限の指標を確認します。Chromeの公式記事も、リークがあるページでは使用量が時間とともに増え、利用者に遅く肥大したページとして感じられると説明しています。

反復計測結果の傾向を判定する例

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class MemorySample:
   iteration: int
   heap_mb: float
   dom_nodes: int
   event_listeners: int


def growth_per_iteration(
   samples: list[MemorySample],
   attribute: str,
) -> float:
   if len(samples) < 2:
       return 0.0

   first = samples[0]
   last = samples[-1]
   iteration_difference = last.iteration - first.iteration

   if iteration_difference <= 0:
       raise ValueError(
           "iterationは増加する順番で指定してください"
       )

   first_value = getattr(first, attribute)
   last_value = getattr(last, attribute)

   return (
       last_value - first_value
   ) / iteration_difference


samples = [
   MemorySample(0, 42.0, 1250, 84),
   MemorySample(5, 47.5, 1390, 94),
   MemorySample(10, 53.1, 1535, 104),
]

print(
   "1反復あたりのヒープ増加:",
   f"{growth_per_iteration(samples, 'heap_mb'):.2f} MB",
)

print(
   "1反復あたりのDOM増加:",
   f"{growth_per_iteration(samples, 'dom_nodes'):.1f}",
)
 

15. 監査報告書と継続的なメモリ管理

メモリリーク監査の成果は、「使用量が大きい」という報告ではなく、どの操作で、何が、どこから保持され、どの利用者へどの程度の影響があるかを説明することです。修正後の再発防止まで管理します。

15.1 問題を操作と保持経路で記載する

「Detached DOMが多い」だけでなく、「商品詳細を閉じるたびに詳細パネルのDOM部分木がグローバルなactivePanels配列から保持され、一反復あたり約二百ノード増える」のように記載します。

再現手順、反復回数、計測条件、対象Constructor、Retainer、増加量を含めます。画面画像だけでなく、Heap SnapshotやAllocation記録への参照を残します。

15.2 利用者影響で重大度を決める

一回の操作で大きなメモリを漏らす問題、すべての画面で発生する問題、低メモリ端末を強制終了させる問題を優先します。小さなリークでも、数秒ごとのタイマーや高頻度操作で起きるなら影響が大きくなります。

保持サイズだけでなく、発生頻度、対象利用者、回避方法、機能誤動作を評価します。再読み込みで一時的に解消できても、入力消失や業務中断があれば重大です。

15.3 暫定対応と根本修正を分ける

長時間画面に定期再読み込みを加える方法は、利用者影響を一時的に軽減する場合がありますが、根本的なリークを解消しません。入力や接続状態を失う危険もあります。

報告書では、利用停止、機能制限、再読み込みなどの暫定対応と、参照解除、所有構造変更、ライブラリー更新などの根本修正を分けます。

15.4 共通原因を設計規則へ反映する

複数部品でイベント解除漏れが見つかった場合、個別修正だけでなく、AbortControllerを使う共通パターン、Effectのcleanup規則、破棄可能な部品インターフェースを導入します。

コードレビュー表には、タイマー、リスナー、Observer、通信、Blob URL、Workerを開始した場合の終了処理を追加します。新しいライブラリーには破棄APIと長時間試験を要求します。

15.5 同じ経路を回帰試験へ残す

修正した問題の再現操作を自動試験または定期監査へ残します。UI構造や依存ライブラリーの変更によって、同じリークが再発する可能性があります。

Chrome DevToolsのMemory機能やAllocation Timelineは、オブジェクトの分布、生成時刻、保持経路を確認するための診断手段です。 日常的な回帰試験では軽量な件数・傾向監視を行い、超過時に詳細診断へ進む運用が現実的です。

評価軸優先度が高くなる状態
保持量一反復で大きな画像・DOM・バッファーが残る
発生頻度スクロールや更新ごとに発生する
影響範囲共通部品で多数画面に発生する
利用時間長時間利用が一般的である
機能影響重複送信や古い状態更新が起こる
回避可能性再読み込み以外に回復方法がない
端末影響モバイル端末で強制終了する

メモリリーク問題の優先度を計算する例

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class MemoryLeakIssue:
   name: str
   retained_size: int
   frequency: int
   affected_scope: int
   functional_impact: int
   recovery_difficulty: int


def priority_score(issue: MemoryLeakIssue) -> float:
   values = [
       issue.retained_size,
       issue.frequency,
       issue.affected_scope,
       issue.functional_impact,
       issue.recovery_difficulty,
   ]

   if any(value < 1 or value > 5 for value in values):
       raise ValueError(
           "各評価は1から5で指定してください"
       )

   return (
       issue.retained_size * 0.25
       + issue.frequency * 0.25
       + issue.affected_scope * 0.20
       + issue.functional_impact * 0.20
       + issue.recovery_difficulty * 0.10
   )


issue = MemoryLeakIssue(
   name="詳細画面を閉じても画像BlobとDOMが残る",
   retained_size=5,
   frequency=4,
   affected_scope=4,
   functional_impact=3,
   recovery_difficulty=4,
)

print(
   f"改善優先度: {priority_score(issue):.2f}"
)
 

おわりに

UIのメモリリークとは、不要になった画面、DOMノード、イベント処理、通信、画像資源などが、残存する参照によって回収されず、操作の反復とともに積み重なる問題です。JavaScriptにはガベージコレクションがありますが、到達可能なオブジェクトは自動的に不要と判断されません。閉じたモーダルを保持する配列、解除されないグローバルリスナー、停止されないタイマー、購読解除漏れ、Blob URL、閉じたWindowへの参照などを明示的に終了する必要があります。

監査では、ページを開いた直後の使用量だけでなく、同じ操作を反復し、最初と同じ画面状態へ戻った後の基準値を比較します。Performance MonitorでDOMノードやリスナーの傾向を確認し、Heap Snapshotで増加オブジェクトとRetainerを調べ、Allocation Timelineで問題操作中に作成されて生存しているオブジェクトを特定します。Detached DOMは代表的な原因ですが、画像、Worker、iframe、グローバルストアなど、JavaScriptヒープ以外の資源も含めて確認することが重要です。

最終的な改善目標は、メモリ使用量を常に小さくすることではありません。画面の作成時に開始した副作用が、破棄時に確実に終了し、反復操作後に不要な状態が残らず、長時間利用しても性能と機能が安定する設計を作ることです。setupとcleanupを同じ所有境界へ置き、反復試験を回帰試験へ残し、実利用時間の長い画面を継続監視することで、再読み込みに頼らない安定したUIへ近づけます。

LINE Chat