代表的な面接トピック

フロントエンド面接:JavaScriptのメモリリークをどのように診断し修正するか?

フロントエンド難しい
Offer.cc 編集チーム公開日 更新日

質問

Reactで構築された注文ダッシュボードSPAにおいて、詳細ルートが開かれるたびにチャートの作成、メッセージの購読、ウィンドウ変更の監視を行います。画面遷移を20往復繰り返すとページが著しく重くなり、手動でガベージコレクションを実行した後でも、ラウンドごとにJSヒープ、DOMノード数、リスナー数が増加し続けます。メモリリークを証明し、Chrome DevToolsでリテーナーパスを特定し、リソースのライフサイクルを修正して、再現可能な受入テストを設計する手順を説明してください。

プロンプトと適用コンテキスト

Reactで構築された注文ダッシュボードSPAには、リアルタイムチャート、注文イベントの購読、レスポンシブレイアウトの監視処理が含まれています。ユーザーが注文詳細ルートを開き、一覧に戻る操作を20回繰り返すと、ページの動作が重くなります。ChromeのPerformanceパネルで記録をとると、各ラウンドの最後に同様の操作を行ってガベージコレクションの機会を与えた後でも、JSヒープの最低ライン(low-water mark)が上昇し続けていることが確認できます。DOMノード数やリスナー数も初期範囲に戻りません。

関連するコンポーネントの簡略化コードは以下のとおりです。

tsx
useEffect(() => {
  const chart = createOrderChart(containerRef.current)

  window.addEventListener("resize", () => chart.resize())
  orderBus.on("order", (order) => chart.append(order))
  const timerId = window.setInterval(() => chart.refresh(), 5_000)

  return () => {
    window.clearInterval(timerId)
  }
}, [])

この面接では、検証可能な証拠の連鎖(evidence chain)が求められます。候補者は、通常のアロケーション、メモリ肥大化(memory bloat)、頻繁なガベージコレクション、そして真のメモリリークを明確に区別できなければなりません。また、GCルートから破棄されるべきオブジェクトがなぜ解放されないのかを特定し、リスナー、購読、タイマー、サードパーティ製インスタンスのライフサイクルを修正し、同一の操作ループによって修正を証明する必要があります。

2025年のフロントエンド公開問題集では、JavaScriptのメモリリークを特定して修正する方法が明示的に問われています。また、別の2025年のJavaScript面接ガイドでも、画面遷移の繰り返しで遅くなるSPAを題材に、DevToolsでリークを特定する手順が取り上げられています。近年の面接体験談では、ガベージコレクション、クロージャ、リスナー、コンポーネントのアンマウントに関する深掘り質問が記録されています。求められているのは単なる「タイマーをクリアする」といった知識の列挙ではなく、ブラウザ上で実際に実行可能な診断ワークフローです。

面接官が評価しているポイント

1つ目のスキルは、妥当な判定基準を設定できるかどうかです。一時的なオブジェクトの増加、ピーク値の上昇、プロセスメモリの増加数値だけでは、リークの証明にはなりません。優れた回答では、ブラウザ、ビルド、データ、操作手順を固定し、ラウンド間に同等のGC機会を与え、複数の生存ベースラインを比較します。通常のアロケーションは上下するノコギリ波の形状を描きます。同一の操作の後に生存し蓄積し続けるオブジェクトがあって初めて、参照パスを調査する正当な理由となります。

2つ目のスキルは、到達可能性(reachability)の理解です。現代のJavaScriptガベージコレクタは、グローバルオブジェクトなどのルートから到達可能なオブジェクトをマークします。アプリケーション側でオブジェクトが不要になったとしても、それだけでコレクタに伝わるわけではありません。windowのリスナー、イベントバス、タイマー、キャッシュ、サードパーティ製ライブラリが強い参照を保持している限り、そのオブジェクトは到達可能のままです。循環参照が存在するだけでリークするわけではありません。ルートからの到達パスがなくなれば、循環グループ全体が一括して回収されます。

3つ目のスキルは、単にMemoryパネルを開くだけでなく、得られた証拠を正しく読み解く能力です。

証拠答えられる問い単独では証明できないこと
タスクマネージャ / Performanceのメモリ曲線繰り返しの操作でメモリが増加し続けているか?どのオブジェクトがそれを保持しているか
Heap Snapshotの比較(Comparison)どの生存型や参照数が増加したか?その増加がキャッシュの仕様を満たしているか
Retainers / リテーナーパスどの参照チェーンがオブジェクトをルートに結びつけているか?所有側のコードがいつそれを解放すべきか
Allocation instrumentation on timelineどの操作が、後に生存し続けたオブジェクトを割り当てたか?すべての割り当てがリークであること
DOMノード、ドキュメント、リスナー数増加の原因がDOMやリスナーに関連しているか?JSヒープ外を含むプロセス全体のメモリ詳細

最後のスキルは、リソースの所有権(resource ownership)です。リソースを作成したライフサイクルの境界が、そのリソースを解放する責任を持ちます。AbortControllersignalで登録されたDOMリスナーを削除できますが、ResizeObserverの切断、WebSocketの切断、イベントバスの購読解除、サードパーティ製チャートの破棄までは行いません。これらのリソースには、それぞれ独自のdisconnectcloseunsubscribe、またはdestroy操作が必要です。

回答前に確認すべき質問

  • 問題の正確な再現手順は何か? 開始点、戻る操作、ラウンドごとの準備完了条件、データ量、繰り返し回数を固定します。ラウンドごとに異なる注文データを使用すると、スナップショットの差分にビジネスデータの差分が混入してしまいます。
  • どの測定値が増加しているのか? OSメモリフットプリント、JSヒープ、DOMノード、ドキュメント、リスナーはそれぞれ異なる領域をカバーします。スナップショット分析かアロケーション分析かを選択する前に、問題を絞り込みます。
  • プロダクト側で意図的にキャッシュしているものはないか? 上限付きルートキャッシュやチャート履歴などは、設計上意図して保持されている場合があります。容量制限、エビクション(破棄)条件、定常状態の期待値を確認して、制御されたキャッシュとリークを区別します。
  • コンポーネントは本当にアンマウントされているか? ルーティングによって非表示、保持、または再利用されている可能性があります。発生しないアンマウントの修正を行う前に、マウントとクリーンアップのログで実際のライフサイクルを確認します。
  • どのオブジェクトがサードパーティ製ライブラリに由来しているか? チャート、エディタ、地図などは、独自のDOM、Worker、Observer、内部リスナーを保持していることがよくあります。コンテナのHTMLをクリアするだけでは、インスタンスは破棄されません。
  • 本番の事象をテスト環境で再現できるか? ヒープスナップショットにはユーザーデータが含まれる可能性があります。サニタイズされたデータを用いた制御された再現を優先し、プライバシーとアクセス制御を適用します。
  • 何をもって成功とするか? デバイスやワークロードに依存しない汎用的な「〇〇MB制限」は存在しません。操作後のベースラインの傾き、生存インスタンス数、ノード/リスナー数、ユーザーから見える処理落ちの有無について合意を取ります。

30秒の回答フレームワーク

「一覧・詳細・一覧という一連の操作を固定し、ウォームアップ後に繰り返し実行して、同一のGCチェックポイントで生存ベースラインを比較します。ヒープの最低点、DOMノード、またはリスナーが蓄積している場合、スナップショットを比較して増加している型を特定し、Retainersを辿ってwindow、イベントバス、またはタイマーへの参照を追跡します。発生源が不明確な場合はAllocation timelineを追加します。その後、作成元でリスナー、購読、タイマー、Observer、チャートを解放します。同一ループを再実行し、インスタンス数の減少、ベースラインの安定化、挙動の正常性を確認して修正を証明します。」

ステップごとの詳細解説

ステップ1: 繰り返しの実験でリークを証明する

比較可能なベースラインの準備から始めます。無関係な拡張機能を最小限に抑えた同一バージョンのChromeを使用し、ウィンドウサイズ、テストアカウント、注文データ、ルートを固定します。リロード後、遅延読み込みモジュール、フォント、接続プール、初回キャッシュが初期化されるように1回ウォームアップを実行します。PerformanceパネルでMemoryを有効にし、以下を実行します。

  1. ガベージコレクションを1回実行し、開始点を記録する。
  2. 毎回同じ「チャート描画完了」条件を待機しながら、一覧・詳細・一覧の往復を5回完了する。
  3. 再度ガベージコレクションを実行し、JSヒープ、DOMノード、ドキュメント、リスナーの最低点を記録する。
  4. これを3グループ繰り返し、任意のピーク値ではなく各グループの最低点を比較する。

GCが実行されるタイミングはランタイムに依存します。また、短い実験ではJIT処理、画像デコード、ネットワーク応答、DevTools自体のオーバーヘッドの影響も受けるため、1ラウンドのみの差分は仮説に過ぎません。最初のグループが高く、その後に安定する場合はウォームアップの可能性があります。各グループで操作回数に比例して新しいOrderChartが1つ、分離されたノード群、リスナー2つが保持され続けているなら、リークの証拠として強固になります。

以下の3つの現象を明確に区別してください。

  • リーク(Leak): 同一操作の後、GC後の生存ベースラインが上昇し続ける。
  • メモリ肥大化(Memory bloat): 定常状態でのメモリ使用量は過剰だが、操作回数に応じて無限に増え続けるわけではない。
  • アロケーション頻発(Allocation churn): 頻繁なGC停止を伴いながらヒープが激しく上下するが、最低点は回復する。過度な一時アロケーションが問題の本質。

タスクマネージャのメモリフットプリントには、DOMストレージなどのプロセス全体のメモリが含まれます。JavaScript Memoryのライブ値は、到達可能なJSヒープにより近い値です。これらの測定値は調査のトリアージには役立ちますが、ヒープスナップショットによる参照の証拠を代替するものではありません。強制GCは診断用の制御手段であり、プロダクトの修正策ではありません。本番コードで特定の瞬間にGCが実行されることを保証することはできません。

ステップ2: スナップショットとリテーナーパスで責任元を特定する

増加が確認されたら、MemoryパネルでスナップショットAを取得します。固定されたナビゲーションループを実行し、一覧に戻って非同期のクリーンアップを待機した後、スナップショットBを取得します。スナップショットのキャプチャはガベージコレクションから開始されるため、Comparison(比較)表示は到達可能なまま残っているオブジェクトの特定に有効です。インスタンスの差分(Delta)やRetained Sizeでソートし、ループ回数に応じて増加しているビジネスコンストラクタ、クロージャ、配列、分離されたDOMツリーを探します。

shallow sizeはオブジェクト自体のサイズを表します。retained sizeは、そのオブジェクトが到達不能になった場合に解放され得る推定メモリ量を表します。小さなリスナーのコールバックであっても、クロージャを通じてチャート、データ配列、DOMサブツリー全体を保持している場合があるため、コールバック自体のサイズよりもRetained Sizeの方が優先順位付けの指標として適しています。これは診断上の見積もり値であり、排他的な物理メモリを直接示すものではありません。

破棄されるべきOrderChartまたは分離されたDOMノードを選択し、Retainers(保持元)を確認します。パスは以下のようになります。

text
Window
└─ resize event listener
   └─ callback closure
      └─ chart
         └─ container
            └─ detached HTMLDivElement

このパスは、GCがグラフを回収できない理由を説明しています。グローバルなwindowが無名のresizeコールバックを保持しており、そのクロージャがチャートを保持しています。DOMをドキュメントから削除しても、ドキュメントとの関係が変わるだけであり、ルートからのJavaScriptの参照パスが切れるわけではありません。修正すべきはリスナーとチャートの所有関係であり、分離されたノードにnullを投機的に代入することではありません。

スナップショットに汎用的なObjectArrayのエントリしか表示されない場合は、Allocation instrumentation on timelineを使用します。記録を開始し、リークが発生する操作を正確に1回実行して停止し、GC後も生存し続けるアロケーションに焦点を当てます。そのコンストラクタとアロケーションスタックをコードまで辿ります。アロケーションサンプリングはオーバーヘッドが低く、アロケーション負荷の高い関数を見つけるのに適していますが、個々のインスタンスやリテーナーの追跡が重要な場面ではスナップショットの代わりにはなりません。

よくある参照保持の原因は以下のとおりです。

  • ページのインスタンスを無制限に追加し続けるグローバルオブジェクトやモジュールレベルの配列
  • 削除されていない、または異なる関数参照やcaptureオプションで削除しようとしたDOM/EventTargetリスナー
  • コンポーネントの状態を保持し続けるsetIntervalコールバックや再帰的タイムアウト
  • 購読解除されていないイベントバス、ストア、WebSocket、Observable
  • 破棄処理が行われていないResizeObserverIntersectionObserver、Worker、サードパーティ製インスタンス
  • 容量制限のないMap、履歴コレクション、リクエストキャッシュ
  • 解決されないPromiseや、クロージャを無期限に保持し続けるキュー内のタスク

ステップ3: リソースの所有権を修正し、同一の受入テストを再実行する

修正されたEffectは、すべてのリソースに対する解放ハンドルを保持します。AbortControllerでDOMリスナーを管理し、残りのリソースは明示的に解放します。

tsx
useEffect(() => {
  const container = containerRef.current
  if (!container) return

  const controller = new AbortController()
  const chart = createOrderChart(container)
  const observer = new ResizeObserver(() => chart.resize())
  const unsubscribe = orderBus.on("order", (order) => chart.append(order))
  const timerId = window.setInterval(() => chart.refresh(), 5_000)

  observer.observe(container)
  window.addEventListener("visibilitychange", () => chart.syncVisibility(), {
    signal: controller.signal,
  })

  return () => {
    controller.abort()
    observer.disconnect()
    unsubscribe()
    window.clearInterval(timerId)
    chart.destroy()
  }
}, [])

実際のAPIの仕様を確認してください。一部のonメソッドはunsubscribe関数を返しますが、別のライブラリではoffに同じハンドラを渡す必要があります。依存関係の変更によってリソースが再作成される可能性がある場合、クリーンアップ処理はそのEffectの実行で作成されたインスタンスのみを解放し、後続のインスタンスを誤って閉じてはなりません。非同期リクエストにもキャンセル処理や世代チェックが必要です。アンマウント後の状態更新(setState)を防ぐだけでは現象の1つに対処したに過ぎず、外部の購読がクロージャを保持している限りリークは残ります。

WeakMapはオブジェクトをキーとするメタデータには適していますが、明示的なライフサイクルのクリーンアップを代替するものではありません。WeakRefはガベージコレクションのタイミングや観察可能な挙動についての保証が意図的に少なく、リスナー、接続、チャートインスタンスの修正手段としては通常不適切です。直接的な修正策は、不要な強い参照を解除し、意図的なキャッシュに容量制限、TTL、またはエビクション条件を設定することです。

受入テストでは、修正前とまったく同じ実験(同一ビルド、データ、遷移回数、準備完了ポイント、GC制御)を再実行します。合格条件は以下のとおりです。

  • 画面を離れた後、生存している詳細コンポーネント、チャートインスタンス、分離されたサブツリーが合意された数に戻ること。
  • 複数のグループにわたり、GC後のベースラインが遷移回数に比例して直線的に増加せず、安定した範囲内で推移すること。
  • リスナー数、ドキュメント数、DOMノード数が期待される範囲に戻ること。
  • マウントごとに1つのメッセージ購読と1つのチャートインスタンスが作成され、アンマウントごとにそれぞれが1回ずつ解放されること。
  • チャートの更新、コンテナのリサイズ、表示切り替えの挙動が正常に機能し、再訪問時にリソースが再作成されること。
  • 長時間のフリーズ、GC停止時間、クラッシュの兆候がプロダクトの許容範囲内に収まっていること。

レビュー用に、修正前後のスナップショット、再現手順、ビルド識別子、主要なリテーナーパスを保存しておきます。スナップショットには文字列やビジネスオブジェクトが含まれる可能性があるため、機密性の高いデバッグ資料として取り扱ってください。

質の高い回答例

「メモリ曲線の上昇は強力な証拠ですが、1回の上昇だけでは結論として不十分です。注文データを固定し、一覧・詳細・一覧を1つの操作として定義し、ウォームアップ後に5回往復を3グループ実行します。各グループの前後の同一ページ状態で手動GCを実行し、ヒープの最低点、DOMノード、ドキュメント、リスナーを記録します。最初のグループのみが上昇し以降が安定する場合は、初期化処理や上限付きキャッシュを疑います。すべてのグループで同じ数のチャートとリスナーが追加され続ける場合は、スナップショットの調査に進みます。」

「ウォームアップ後にスナップショットAを取得し、操作ループを実行して一覧に戻り、クリーンアップを待ってからスナップショットBを取得します。Comparisonビューでインスタンス数とRetained Sizeの差分から確認します。破棄されるべきOrderChartインスタンスが15個残り、RetainersにWindow → resize listener → closure → chart → detached containerが含まれているとします。 このパスから原因が特定できます。windowが無名リスナーを保持し続け、そのクロージャがチャートとDOMを保持しています。コンストラクタ名が汎用的な場合は、Allocation timelineを1回記録し、アロケーションスタックを使用して、その遷移で生存したオブジェクトをソースコードにマッピングします。」

「修正では、Effect内ですべての解放ハンドルを保持させます。DOMリスナーにはAbortControllerを使用し、イベントバスは購読解除し、タイマーはクリアし、Observerは切断し、チャートはdestroyします。ライブラリがoff(handler)を要求する場合は、同一のハンドラ参照を維持します。キャッシュには容量制限やエビクションを設定します。同一の実験を再実行し、チャートと分離サブツリーが合意した数に戻り、GC後ベースラインがナビゲーション回数に追従しなくなり、再訪問時にも正常に購読と描画が行われることを確認して完了とします。」

よくある間違い

  • 1つのピークだけでリークと断定する → 通常のアロケーション、JIT処理、キャッシュでもピークは上がります → GC後のベースラインを複数回比較し、インスタンス数と操作回数の相関を確認してください。
  • 分離されたDOMを見てコンテナを空にするだけで済ませる → グローバルリスナーやクロージャが依然としてそれを保持している可能性があります → Retainersをルートまで辿り、真の保持元を解放してください。
  • すべての構造をWeakMapやWeakRefに置き換える → 他の強い参照が存在すればオブジェクトは到達可能なままです → まずは不要なリスナー、購読、タイマー、キャッシュ参照を削除してください。
  • アンマウント後のsetStateを防ぐだけで終わらせる → 外部リソースが依然としてコールバックやオブジェクトグラフを保持している可能性があります → 非同期処理をキャンセルし、登録を解除してください。
  • ページがクリック可能であることだけを確認する → 機能的に動作することとメモリが解放されていることは別です → 同一のスナップショット実験を繰り返し、生存インスタンス、ベースライン、リテーナーパスを比較してください。

フォローアップ質問と回答

フォローアップ1: なぜ循環参照は必ずしもリークにならないのですか?

マーク&スイープアルゴリズムは、ルートからオブジェクトに到達できるかどうかを判定します。2つのオブジェクトが互いを参照し合っていても、外部の到達可能なパスからそのグループへの参照が存在しなければ回収されます。windowのリスナー、モジュールキャッシュ、アクティブなタイマーなどがそのうちの1つに到達している場合に限り、グループ全体が到達可能なまま残ります。

フォローアップ2: 削除されたDOMノードがスナップショットに残る原因は何ですか?

DOMの削除は、ドキュメントツリーからノードを切り離すだけです。JavaScriptの変数、リスナーのコールバック、サードパーティ製コンポーネント、Observerが依然としてそのノードを参照している可能性があります。分離されたノードのRetainersを調査し、パス上の生存している保持元を特定して、その保持元の解放処理を実行してください。

フォローアップ3: Heap Snapshot、Allocation timeline、サンプリングはどのように使い分けますか?

Heap Snapshotは、安定したポイントで生存オブジェクトを比較し、インスタンスごとのリテーナーパスを明らかにするのに適しています。Allocation timelineは、ユーザー操作と、その後に生存し続けるアロケーションとを結びつけます。アロケーションサンプリングは、低いオーバーヘッドでアロケーション負荷の高い関数を特定するのに役立ちます。実践的な手順としては、まずメモリ曲線で問題を証明し、スナップショットでオブジェクトと参照を特定し、発生源が不明な場合にアロケーション記録を追加します。

フォローアップ4: 「メモリ増加を10MB未満にする」を受入テストの自動化基準にできますか?

固定の絶対値は、デバイス、ブラウザ、ビルド、データ、GCのタイミングに左右されやすいです。より強固な基準にするには、環境と操作を固定し、ウォームアップを行い、複数グループを実行した上で、GC後ベースラインの傾き、監視対象コンストラクタの生存数、DOM/リスナー数を確認します。そのページの過去の分布とプロダクトの許容範囲に基づいて、ノイズへの許容幅を持たせた閾値を設定してください。

フォローアップ5: ページが非表示になるだけでアンマウントされない場合はどうしますか?

プロダクトの仕様を確認します。意図的なkeep-alive動作の場合は、タイマーの一時停止、サンプリングレートの低減、非表示時の購読停止と復帰時の再開を行い、キャッシュとインスタンス数には上限を設けます。「一覧に戻った後にゼロになる」という基準は適用されなくなります。受入条件としては、安定的で上限のあるメモリ使用量であること、そしてコンポーネントが実際に破棄された際には完全に解放されることを要求すべきです。

公開情報ソース

関連する質問