はじめに:実務でこんな症状はありませんか?
Webアプリケーションを開発・運用していると、こんな問題に直面することがあります。
- 「最初は快適だったのに、30分使うとブラウザが重くなる…」
- 「ページ遷移を繰り返すと、メモリ使用量がどんどん増える」
- 「タスクマネージャーを見ると、タブのメモリが1GBを超えている」
- 「長時間使っているとブラウザがクラッシュする」
- 「ユーザーから『重い』という報告が来るが、原因が分からない」
これらの症状は、メモリリークが原因かもしれません。
この記事では、Chromeデベロッパーツールのメモリタブの具体的な使い方と、メモリリークを特定・解決する手順を実例付きで詳しく解説します。
※この記事は実践編です。メモリ管理の基礎から学びたい方は、まず【基礎編】Chromeデベロッパーツールのメモリタブとは?をご覧ください。
事前準備:メモリリークの症状を確認する
Chrome タスクマネージャーで確認
まず、本当にメモリリークが発生しているか確認しましょう。
手順:
- Chrome のメニュー(︙)→「その他のツール」→「タスクマネージャー」を開く
- テーブルのヘッダーを右クリック → 「JavaScript メモリ」にチェックを入れる
確認ポイント:
| 列 | 意味 | 確認すること |
|---|---|---|
| メモリ使用量 | OSメモリ(DOM含む) | この値が増え続けていないか |
| JavaScript メモリ | JSヒープ(カッコ内がライブメモリ) | ライブメモリが増え続けていないか |
JavaScript メモリの見方:
例:52,428 (45,876)
↑ ↑
| └─ ライブメモリ(実際に使用中のメモリ)
└──────── 総メモリ(確保済みのメモリ)
メモリリークの兆候:
- 操作を繰り返すと、ライブメモリ(カッコ内の数値)が増加し続ける
- 何もしていないのに、メモリ使用量が減らない
- ページをリロードすると一時的にメモリが解放されるが、また増えていく
判断基準:
- 問題の操作を10回繰り返す
- 何もせずに30秒待つ(ガベージコレクションの時間を与える)
- ライブメモリが操作前より50%以上増えていたら、メモリリークの疑いあり
メモリタブの開き方・基本操作
開き方
- デベロッパーツールを開く(
F12またはCtrl + Shift + I/Cmd + Option + I) - 上部タブに「メモリ」があればクリック
- なければ「>>」をクリックして「メモリ」を選択
メモリタブの画面構成
メモリタブを開くと、以下の4つのプロファイリングタイプが選択できます:
| プロファイリングタイプ | 用途 | よく使う場面 |
|---|---|---|
| ヒープスナップショット | 特定時点のメモリ状態を記録・比較 | メモリリーク調査の基本(最頻出) |
| タイムラインへの割り当て | 時系列でメモリ割り当てを記録 | どのタイミングでメモリが増えるか調査 |
| 割り当てサンプリング | サンプリング方式で関数ごとのメモリ使用量を記録 | 長時間動作するアプリの調査 |
| デタッチされた要素 | 切り離されたDOM要素を検出 | SPA でのページ遷移後の確認 |
ヒープスナップショットの使い方
ヒープスナップショットは、メモリリーク調査の基本となるツールです。特定のタイミングでメモリの「写真」を撮り、比較することで増加したオブジェクトを特定できます。
基本的な手順
ステップ1:初期状態のスナップショットを取得
- メモリタブで「ヒープスナップショット」を選択
- 「ヒートスナップショットを撮る」ボタンをクリック(「●」ボタン)
- 数秒待つと、スナップショットが作成される(Snapshot 1)
ステップ2:問題の操作を実行
- メモリリークを引き起こすと思われる操作を実行
- 例:モーダルを10回開閉、ページ遷移を5回、データ読み込みを10回など
- ポイント: 1回だけでなく、複数回繰り返すことで、メモリリークが明確になる
ステップ3:操作後のスナップショットを取得
- 左上の「ヒートスナップショットを撮る」ボタンをクリックして、ガベージコレクションを強制実行
- 再度「ヒートスナップショットを撮る」ボタンをクリック
- 2つ目のスナップショットが作成される(Snapshot 2)
ステップ4:スナップショットを比較
- 左側のパネルで「Snapshot 2」を選択
- 上部のドロップダウンから「比較を」を選択
- 比較対象が「Snapshot 1」になっていることを確認
- 増加したオブジェクトが表示される
スナップショットの表示モード
| 表示モード | 説明 | 使いどころ |
|---|---|---|
| 概要 | コンストラクタごとにグループ化 | どの種類のオブジェクトが多いか確認 |
| 比較 | 2つのスナップショットを比較 | メモリリーク調査(最重要) |
| 包含関係 | オブジェクトの参照関係を表示 | メモリ構造の詳細分析 |
| 統計情報 | メモリ使用量の円グラフ | 全体像の把握 |
比較 モードの見方
比較モードでは、以下の列が表示されます:
| 列 | 意味 | 確認ポイント |
|---|---|---|
| コンストラクター | オブジェクトの種類 | Array、Object、HTMLDivElement など |
| #増加数 | 新しく作られたオブジェクトの数 | – |
| #削除 | 削除されたオブジェクトの数 | – |
| #デルタ | 増減(#New – #Deleted) | 正の大きな値 = メモリリークの可能性 |
| 割り当てサイズ | 新しく割り当てられたメモリサイズ | – |
| フリーサイズ | 解放されたメモリサイズ | – |
| サイズの差分 | メモリサイズの増減 | 大きい値 = メモリ使用量が増えている |
メモリリークの兆候:
- #デルタが正の大きな値(例:+100、+1000)→ オブジェクトが増え続けている
- サイズの差分が大きい(例:+10MB)→ メモリ使用量が増えている
- 特に、(closure)、Array、Object、HTMLElement の増加に注目
デタッチされた要素の確認
デタッチされた要素とは、ページから削除されたが、JavaScriptから参照されているため、メモリに残っているDOM要素のことです。
確認手順:
- 比較 モードで、上部のクラスでフィルターに
Detachedと入力 Detached HTMLDivElementなどが表示される- オブジェクトをクリック
- 下部の「Object」パネルで、どのコードが参照しているかを確認
確認ポイント:
- デタッチされた要素が大量にある(数百〜数千)→ メモリリークの可能性大
- Retainers(保持者)を見て、どの変数が参照しているか特定
実務での解決事例1:イベントリスナーのメモリリーク
症状
- モーダルを開閉するたびにメモリが増える
- 100回開閉すると、ブラウザが重くなる
- タスクマネージャーでライブメモリが増え続けている
問題のコード
// 悪い例:イベントリスナーを削除していない
function showModal() {
const modal = document.getElementById('modal');
const closeButton = document.getElementById('closeButton');
modal.style.display = 'block';
// モーダルを閉じる処理
closeButton.addEventListener('click', function() {
modal.style.display = 'none';
});
}
// この関数を何度も呼ぶと、イベントリスナーが溜まっていく
document.getElementById('openButton').addEventListener('click', showModal);
何が問題か?
showModalが呼ばれるたびに、新しいイベントリスナーが追加される- 古いイベントリスナーは削除されないため、メモリに残り続ける
- イベントリスナーは、関数のクロージャを保持するため、メモリを消費する
メモリタブでの確認手順
- ヒープスナップショット を取得(Snapshot 1)
- モーダルを10回開閉
- 「ヒートスナップショットを撮る」ボタンをクリックして、ガベージコレクションを実行
- 再度ヒープスナップショットを取得(Snapshot 2)
- 比較モードで比較
- (closure) または system / EventListener が増えていることを確認
確認結果の例:
Constructor #Delta Size Delta
(closure) +10 +2.5 KB
system / EventListener +10 +800 B
→ モーダルを10回開閉したので、クロージャが10個増えている
解決方法
方法1:removeEventListener で削除する
// 良い例:イベントリスナーを適切に管理
let currentCloseHandler = null;
function showModal() {
const modal = document.getElementById('modal');
const closeButton = document.getElementById('closeButton');
modal.style.display = 'block';
// 前回のリスナーがあれば削除
if (currentCloseHandler) {
closeButton.removeEventListener('click', currentCloseHandler);
}
// 新しいリスナーを登録
currentCloseHandler = function() {
modal.style.display = 'none';
};
closeButton.addEventListener('click', currentCloseHandler);
}
方法2:once オプションを使う(推奨)
// さらに良い例:once オプションで自動削除
function showModal() {
const modal = document.getElementById('modal');
const closeButton = document.getElementById('closeButton');
modal.style.display = 'block';
closeButton.addEventListener('click', function() {
modal.style.display = 'none';
}, { once: true }); // 一度だけ実行され、自動的に削除される
}
改善結果
Before:
- 10回開閉後:(closure) が +10
- メモリ増加:約 2.5 KB
- 100回開閉後:約 25 KB(累積)
After:
- 10回開閉後:(closure) の増加なし
- メモリ増加:ほぼなし(GCで回収される)
実務での解決事例2:タイマーのメモリリーク
症状
- リアルタイムダッシュボードを長時間表示していると、メモリが増え続ける
- ページ遷移してもメモリが解放されない
- 1時間後には数百MBのメモリを消費している
問題のコード
// 悪い例:setInterval を停止していない
function startRealtimeUpdate() {
setInterval(function() {
fetch('/api/data')
.then(res => res.json())
.then(data => {
updateDashboard(data);
});
}, 5000); // 5秒ごとに更新
}
// ページをマウントした時に開始
startRealtimeUpdate();
// 問題:ページ遷移しても setInterval が動き続ける
// 取得したデータが配列などに蓄積されていく
何が問題か?
setIntervalは、明示的に停止しない限り永遠に動き続ける- API レスポンスのデータがメモリに蓄積される
- ページ遷移しても、タイマーは動き続ける(SPAの場合)
メモリタブでの確認手順
- ヒープスナップショットを取得(Snapshot 1)
- 5分間待つ(60回のAPI呼び出しが発生)
- 再度ヒープスナップショットを取得(Snapshot 2)
- 比較モードで、Array や Object が大量に増えていることを確認
確認結果の例:
Constructor #Delta Size Delta
Array +60 +12 MB
Object +600 +25 MB
→ API レスポンスのデータが蓄積されている
解決方法
// 良い例:タイマーを適切に管理
let updateIntervalId = null;
function startRealtimeUpdate() {
// 既存のタイマーがあれば停止
if (updateIntervalId !== null) {
clearInterval(updateIntervalId);
}
updateIntervalId = setInterval(function() {
fetch('/api/data')
.then(res => res.json())
.then(data => {
updateDashboard(data);
});
}, 5000);
}
function stopRealtimeUpdate() {
if (updateIntervalId !== null) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
}
// React の場合
function DashboardComponent() {
useEffect(() => {
startRealtimeUpdate();
// クリーンアップ関数で停止
return () => {
stopRealtimeUpdate();
};
}, []); // 空の依存配列 = マウント時のみ実行
return <div>{/* ダッシュボードの内容 */}</div>;
}
// Vue 3 の場合
export default {
mounted() {
startRealtimeUpdate();
},
beforeUnmount() {
stopRealtimeUpdate();
}
}
改善結果
Before:
- 5分後:メモリ増加 約 37 MB
- 1時間後:メモリ増加 約 450 MB
- ページ遷移後もタイマーが動き続ける
After:
- 5分後:メモリ増加 約 2 MB(正常範囲)
- ページ遷移時に適切にクリーンアップ
- メモリ使用量が安定
実務での解決事例3:デタッチされた要素のメモリリーク
症状
- SPA(Single Page Application)でページ遷移を繰り返すと、メモリが増える
- リストの要素を1000件追加→削除しても、メモリが減らない
- タスクマネージャーで数百MBのメモリが解放されない
問題のコード
// 悪い例:削除した DOM への参照を保持
let savedElements = [];
function createListItem(text) {
const li = document.createElement('li');
li.textContent = text;
document.getElementById('list').appendChild(li);
// 配列に保存(これが問題)
savedElements.push(li);
return li;
}
function clearList() {
const list = document.getElementById('list');
list.innerHTML = ''; // DOM からは削除される
// しかし、savedElements がまだ参照を保持している
// → デタッチされた要素としてメモリに残る
}
// 使用例
for (let i = 0; i < 1000; i++) {
createListItem(`Item ${i}`);
}
clearList(); // DOM は削除されるが、メモリには残る
何が問題か?
- DOM要素を
savedElements配列に保存している clearList()でDOMからは削除されるが、配列がまだ参照を保持- ガベージコレクタは「まだ使われている」と判断し、メモリを解放しない
メモリタブでの確認手順
- ヒープスナップショット を取得(Snapshot 1)
- リストを作成(1000件)
- リストをクリア
- 「ヒートスナップショットを撮る」ボタンでガベージコレクションを実行
- 再度 ヒープスナップショット を取得(Snapshot 2)
- 比較モードを選択
- クラスでフィルター に「Detached」と入力
- 「Detached HTMLLIElement」が1000個あることを確認
詳細確認:
- 「Detached HTMLLIElement」をクリック
- 下部の「Object」パネルを見る
- 「Retainers」セクションで「savedElements」が参照していることを確認
- 青い文字(ファイル名と行番号)をクリックすると、該当コードが表示される
解決方法
方法1:DOM削除時に参照も削除
// 良い例:DOM 削除時に参照も削除
let savedElements = [];
function createListItem(text) {
const li = document.createElement('li');
li.textContent = text;
document.getElementById('list').appendChild(li);
savedElements.push(li);
return li;
}
function clearList() {
const list = document.getElementById('list');
list.innerHTML = '';
// 参照も削除
savedElements = [];
}
方法2:WeakMap を使う(推奨)
// さらに良い例:WeakMap を使う
let elementData = new WeakMap();
function createListItem(text, data) {
const li = document.createElement('li');
li.textContent = text;
document.getElementById('list').appendChild(li);
// WeakMap を使えば、DOM が削除されると自動的にエントリも削除される
elementData.set(li, data);
return li;
}
function clearList() {
const list = document.getElementById('list');
list.innerHTML = '';
// WeakMap なので、参照のクリアは不要
// DOM が削除されると、自動的にガベージコレクション対象になる
}
WeakMap とは?
- キーとして使ったオブジェクトが削除されると、自動的にエントリも削除される
- 通常の Map や配列と違い、ガベージコレクションを妨げない
- DOM 要素とデータを紐付けたい時に最適
改善結果
Before:
- 1000件作成→削除後:Detached HTMLLIElement が1000個
- メモリ増加:約 10 MB(解放されない)
- 10回繰り返すと:約 100 MB の無駄なメモリ消費
After:
- 1000件作成→削除後:デタッチされた要素なし
- メモリ増加:ほぼなし(GCで回収)
- 何度繰り返しても安定
タイムラインへの割り当ての使い方
タイムラインへの割り当ては、時系列でメモリの変化を記録し、どのタイミングでメモリが増えるかを視覚化します。
使用手順
- メモリタブで「タイムラインへの割り当て」を選択
- 「Start」ボタンをクリック(記録開始)
- メモリリークを疑う操作を実行(例:モーダル開閉を10回)
- 「Stop」ボタンをクリック(記録停止)
タイムラインの見方
タイムラインには、縦に伸びる青いバーが表示されます。
| 表示 | 意味 |
|---|---|
| 青いバーが高い | そのタイミングで多くのメモリが割り当てられた |
| 青いバーが残る | そのメモリがまだ解放されていない |
| 青いバーがグレーになる | ガベージコレクションで回収された(正常) |
メモリリークの兆候:
- 操作を繰り返すたびに、青いバーが増え続ける
- 青いバーがグレーにならない(GCで回収されない)
- 時間が経っても、青いバーの高さが減らない
詳細分析の手順
- タイムライン上で、気になる青いバーの期間をドラッグして選択
- 下部に、その期間で割り当てられたオブジェクトが表示される
- オブジェクトを展開して、具体的な内容を確認
- オブジェクトをクリックして、どのコードが原因か確認
- 「リテーナー」で、どこから参照されているか確認
使いどころ:
- 「どの操作でメモリが増えるのか分からない」という時
- 複雑な処理で、どこがボトルネックか特定したい時
- ヒープスナップショット だけでは原因が分からない時
デタッチされた要素プロファイルの使い方
デタッチされた要素は、切り離されたDOM要素だけを検出する専用ツールです。
使用手順
- メモリタブで「デタッチされた要素」を選択
- 「ヒートスナップショットを撮る」ボタンをクリック
- デタッチされた要素のリストが表示される
表示内容:
| 列 | 意味 |
|---|---|
| デタッチされたノード | 切り離されたDOM要素の種類と数 |
| ノード数 | 該当する要素の数 |
詳細確認:
- デタッチされたノードをクリック
- 実際のHTML要素が表示される
- 「Show retaining JavaScript object」をクリック
- どのJavaScriptコードが参照しているか表示される
ヒープスナップショット との違い:
- デタッチされた要素: DOM要素に特化、見やすい
- ヒープスナップショット: すべてのオブジェクトを含む、詳細
使い分け:
- DOM要素のメモリリークを疑う時 → デタッチされた要素(簡単)
- より詳細に調査したい時 → ヒープスナップショット(詳細)
よくあるハマりポイントと対処法
ハマりポイント1:ガベージコレクションのタイミング
症状:
- スナップショットを取ってもメモリが減らない
- 「メモリリークかも?」と思ったが、実際は違った
原因: ガベージコレクションがまだ実行されていない
対処法:
- スナップショットを取る前に、プロファイルを全て削除をクリック
- 数秒待ってから、再度スナップショットを取る
- または、何度かスナップショットを取って、傾向を見る
判断基準:
- GC後もメモリが減らない → メモリリーク
- GC後にメモリが減る → 正常(ガベージコレクションが間に合っていなかっただけ)
ハマりポイント2:デベロッパーツール自体のメモリ消費
症状:
- デベロッパーツールを開いているだけで、メモリ使用量が増える
- タスクマネージャーで「Developer Tools」のメモリが大きい
原因: デベロッパーツール自体がメモリを消費している
対処法:
- 調査が終わったらデベロッパーツールを閉じる
- または、記録中は必要最小限のパネルだけを開く
- Elements タブは特にメモリを消費するので、使わない時は閉じる
ハマりポイント3:スナップショットの比較対象が間違っている
症状:
- 比較モードで、意味のない差分が表示される
- すべてのオブジェクトが増加しているように見える
原因: 比較する2つのスナップショットの条件が異なる
正しい手順:
- Snapshot 1: 初期状態(またはリセット後)
- 問題の操作を実行(例:モーダル10回開閉)
- ガベージコレクションを実行
- Snapshot 2: 操作後
- Snapshot 2 で 比較モードを選択
ベストプラクティス:
- 「操作前 → 操作 → 元に戻す → GC → スナップショット」という流れ
- 元に戻した状態でもメモリが減っていなければ、メモリリーク確定
ハマりポイント4:スナップショットが巨大で処理が遅い
症状:
- スナップショット取得に数分かかる
- 比較モードで固まる
原因: メモリ使用量が多すぎる(数GB)
対処法:
- 不要なタブを閉じる
- ブラウザ拡張機能を無効化(シークレットモードで開く)
- 問題を再現できる最小限のページで検証する
- データ量を減らす(例:リスト1000件 → 100件)
実践的なチェックリスト
メモリリークを疑ったら、このチェックリストを順番に確認しましょう。
| 確認項目 | 確認方法 | 対処法 |
|---|---|---|
| □ イベントリスナーの削除忘れ | ヒープスナップショットで (closure) 増加を確認 | removeEventListener または { once: true } |
| □ タイマーの停止忘れ | コードレビュー、タイムラインへの割り当て | clearInterval / clearTimeout |
| □ デタッチされた要素 | ヒープスナップショットで「Detached」検索 | DOM削除時に JS の参照も削除 |
| □ グローバル変数への蓄積 | ヒープスナップショットで Array・Object 増加 | 不要なデータを削除、WeakMap 使用 |
| □ クロージャの参照保持 | ヒープスナップショットで (closure) 増加 | 必要な値だけを保持 |
| □ フレームワークのクリーンアップ | コンポーネントのライフサイクル確認 | useEffect cleanup, onBeforeUnmount 等 |
| □ Fetch のキャンセル忘れ | ネットワークタブで確認 | AbortController を使用 |
| □ Observer の disconnect 忘れ | コードレビュー | IntersectionObserver.disconnect() |
React・Vue での実践的なメモリ管理
React でのベストプラクティス
import { useEffect, useRef, useState } from 'react';
function RealtimeDashboard() {
const [data, setData] = useState(null);
const intervalRef = useRef(null);
useEffect(() => {
// タイマーを開始
intervalRef.current = setInterval(() => {
fetch('/api/data')
.then(res => res.json())
.then(setData);
}, 5000);
// クリーンアップ(コンポーネントのアンマウント時に実行)
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []); // 空の依存配列 = マウント時のみ実行
return <div>{/* data を表示 */}</div>;
}
Vue 3 でのベストプラクティス
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const data = ref(null);
let intervalId = null;
onMounted(() => {
// タイマーを開始
intervalId = setInterval(async () => {
const res = await fetch('/api/data');
data.value = await res.json();
}, 5000);
});
onBeforeUnmount(() => {
// クリーンアップ
if (intervalId) {
clearInterval(intervalId);
}
});
</script>
<template>
<div>{{ data }}</div>
</template>
AbortController でフェッチをキャンセル
// コンポーネントがアンマウントされた時に、実行中のfetchをキャンセル
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(setUser)
.catch(err => {
// AbortError は無視
if (err.name !== 'AbortError') {
console.error(err);
}
});
// クリーンアップ:fetch をキャンセル
return () => {
controller.abort();
};
}, [userId]);
return <div>{user?.name}</div>;
}
まとめ:メモリタブを使いこなすための重要ポイント
基本操作の復習
- ✅ Chrome タスクマネージャーで、まずメモリリークの兆候を確認
- ✅ ヒープスナップショットで操作前後を比較し、増加したオブジェクト
- ✅ 比較モードで #デルタ と サイズの差分 を確認
- ✅ クラスでフィルター に「Detached」を入力して、デタッチされた要素を検索
- ✅ タイムラインへの割り当てで時系列の変化を記録
- ✅ スナップショット前にゴミ箱アイコンでGCを実行
メモリリーク対策の鉄則
- イベントリスナーは必ず削除する →
removeEventListenerまたは{ once: true } - タイマーは必ず停止する →
clearInterval/clearTimeout - DOM削除時はJavaScriptの参照も削除 → 配列を空にする、
WeakMapを使う - グローバル変数を避ける → スコープを限定、必要なデータだけ保持
- フレームワークのクリーンアップを活用 →
useEffectの return、onBeforeUnmountなど - Observer は必ず disconnect →
IntersectionObserver、MutationObserverなど - Fetch は必要に応じてキャンセル →
AbortControllerを使用
トラブルシューティングの流れ
1. 症状の確認
↓
Chrome タスクマネージャーでメモリ増加を確認
2. 初期調査
↓
ヒープスナップショットを操作前後で取得・比較
3. 原因の特定
↓
- デタッチされた要素を検索
- (closure) / EventListener の増加を確認
- Object パネルの Retainers でコードの場所を特定
4. 修正
↓
適切なクリーンアップ処理を追加
5. 検証
↓
再度 ヒープスナップショット で改善を確認
次のステップ
メモリタブを使いこなせるようになったら、次は以下のツールも学んでみましょう:
- Performanceタブ: メモリ使用量とCPU使用量を同時に記録・分析
- Performance Monitor: リアルタイムでメモリ・CPU・DOMノード数を監視
- Lighthouse: パフォーマンス全体の総合評価
これらを組み合わせることで、より総合的なパフォーマンス改善が可能になります。
参考資料:
- Chrome DevTools – Memory panel overview(公式ドキュメント)
- Fix memory problems(公式ガイド)
- 4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them
この記事が役に立ったら、ぜひ実際の開発で活用してみてください!
メモリタブを使いこなすことで、メモリリークの発見・修正が劇的に効率化されます。最初は難しく感じるかもしれませんが、何度か使っていくうちに自然と身につきます。
困ったときは、この記事を見返して、チェックリストを順番に確認してみてください。快適なWebアプリケーションを作りましょう!









