メモリ・状態・そして静かに積み上がる技術的負債
React、Vue、Angular といった SPA フレームワークは、リッチで滑らかな UI を実現する強力な道具です。
状態管理、再描画、最適化のための仕組みも豊富に用意されています。
しかし、その裏側には 見えにくく、気づいたときには手遅れになりやすいコスト が存在します。
それは メモリ管理と状態の寿命 です。
長時間稼働する SPA がなぜ徐々に重くなっていくのか、
そしてその構造的な理由を掘り下げます。
🧠 SPA が抱える「構造的負債」
SPA は Web の責務分担を根本から変えました。
ブラウザがやってくれていた仕事
従来の Multi Page Application(MPA)では、
- ページ遷移 = JavaScript 実行環境の破棄
- DOM の破棄
- イベントリスナーの消滅
- タイマーの停止
これらは ブラウザが自動で行ってくれていました。
SPA の世界
SPA では違います。
- ページは「切り替わる」が、実行環境は残る
- グローバルオブジェクトは生き続ける
- 明示的に消さない限り、何も消えない
メモリ管理の責任が、ブラウザから開発者へ移動した
これがすべての始まりです。

👻 「ゴースト」が積み上がる理由
多くの SPA が重くなる原因は、
1 つの大きなリークではありません。
ほとんどの場合、原因はこれです。
よくある蓄積ポイント
- 解除されないイベントリスナー
- 止まらないタイマー
- 大きな変数を捕まえたクロージャ
- 増え続けるグローバルステート
- 参照が残ったままの DOM
1 つ 1 つは小さい。
しかし積み上がると、確実に効いてきます。
🧟 例1: 「ゾンビ化」するイベントリスナー
function ScrollTracker() {
useEffect(() => {
const handler = () => {
console.log("scroll tracking");
};
window.addEventListener("scroll", handler);
// ❌ クリーンアップなし
}, []);
}
何が起きているか
windowは SPA 中ずっと生き続ける- イベントリスナーが解除されない
handlerがコンポーネントのスコープを保持- コンポーネント全体が GC されない
正しい書き方
useEffect(() => {
const handler = () => console.log("scroll tracking");
window.addEventListener("scroll", handler);
return () => {
window.removeEventListener("scroll", handler);
};
}, []);
これを数回忘れるだけで、
SPA のメモリは 元の水準に戻らなくなります。
⏱️ 例2: 止まらないタイマー
useEffect(() => {
const id = setInterval(fetchUpdates, 5000);
// ❌ clearInterval されない
}, []);
SPA では、
- 画面遷移後もタイマーが動き続ける
- API リクエストが永遠に飛び続ける
- クロージャが状態を保持し続ける
MPA や Hotwire の遷移なら、
- ページ遷移時にスクリプトごと破棄
- 明示的な後始末は不要
🌳 例3: Retained DOM(分離された DOM)
let cachedNode;
function cacheElement() {
cachedNode = document.querySelector("#heavy-widget");
}
// 後で削除
document.querySelector("#heavy-widget").remove();
DOM から消えても、
- JavaScript 変数が参照を保持
- DOM ツリー全体がメモリに残る
- GC が回収できない
SPA ではこれが、
- クロージャ
- refs
- グローバルストア
- メモ化された selector
などを通じて 気づかないうちに発生 します。

🧨 例4: マイクロリークの蓄積
現実的なケースを考えます。
- 1 日で 50 画面を遷移
- 各画面で 10MB 程度のリーク
- Redux / Pinia の state が解放されない
8 時間後には、
- 起動時: 約 150MB
- 終業時: 1.2GB 以上
- GC が頻発
- スクロールが引っかかる
- 入力遅延が発生
何も壊れていない。
ただ 重くなり続けているだけ。
⚖️ 一番厄介な点: 症状が遅れて出る
SPA の怖さはここです。
- 朝は快適
- 昼も問題なし
- 夕方から突然重くなる
これは GC スラッシング の兆候です。
- GC の実行頻度が増える
- 1 回あたりの停止時間が伸びる
- アニメーションやスクロールがカクつく
しかも、
- メモリスナップショットでは分かりにくい
- 再現に長時間操作が必要
- 原因が分散している
調査コストが非常に高い。
🔄 状態同期の負債(State Desync)
SPA はほぼ必ず 二重の状態 を持ちます。
| 場所 | 役割 |
|---|---|
| サーバ | 真実 |
| クライアント | キャッシュ |
これを同期するために、
- useEffect
- ポーリング
- WebSocket
- 無効化ロジック
- 手動リセット
が増えていきます。
画面が増えるほど、
ズレる可能性も増える。
📉 パフォーマンス劣化の形
SPA: なだらかな右肩上がり
- メモリの基準値が上昇
- GC コストが増大
- 操作遅延が蓄積
MPA / Hotwire: ノコギリ型
- 遷移時に一時的に増える
- すぐに元に戻る
- 長時間でも安定
🛡️ Hotwire がこの問題をどう変えるか
Hotwire は極端に振り切りません。
- SPA でもない
- 完全リロードでもない
HTML Over The Wire という中間解です。
Turbo Drive のライフサイクル
- リンククリックをフック
- HTML を取得
<body>を置き換え- ライフサイクルイベントを発火
重要なのはここです。
DOM を物理的に入れ替える
これにより、
ブラウザ本来の GC が最大限に働きます。

🧹 設計による自動クリーンアップ
- DOM に紐づくイベントは自然消滅
- 分離 DOM は即解放
- タイマーも消える
- クロージャが参照を失う
多くの UI で
明示的な後始末が不要 になります。
🗑️ パターン: 削除確認
<div id="item_42" data-controller="confirm">
<a
href="/documents/42"
data-turbo-method="delete"
data-action="click->confirm#ask">
Delete
</a>
</div>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
ask(event) {
if (!window.confirm("Are you sure?")) {
event.preventDefault()
event.stopImmediatePropagation()
}
}
}
- モーダル状態なし
- グローバル state なし
- 後片付け不要
✅ パターン: サーバ主導のバリデーション
<turbo-frame id="signup">
<form action="/users" method="post">
<input name="email" value="<%= @user.email %>">
<% if @user.errors[:email].any? %>
<p><%= @user.errors[:email].join(", ") %></p>
<% end %>
<button>Submit</button>
</form>
</turbo-frame>
def create
@user = User.new(params.require(:user).permit(:email))
if @user.save
redirect_to dashboard_path
else
render :new, status: :unprocessable_entity
end
end
なぜスケールするか
- 真実は常にサーバ
- クライアントのズレが起きない
- エラー状態を手動で消さない
- 送信量が最小
🔐 セキュリティも自然に強い
CSRF
Turbo が自動でトークンを付与。
XSS
サーバテンプレートの自動エスケープを活用。
ロジック露出
クライアントは「結果」だけを見る。
🧠 JS 疲労は現実問題
SPA では、
- ビルドツール
- 依存管理
- クライアントルーティング
- 状態同期
- キャッシュ無効化
Hotwire では、
- HTTP
- HTML
- 小さな controller
- ブラウザ標準挙動
管理対象が圧倒的に少ない。

🎯 最後に
SPA は遅いから問題なのではありません。
止まらないから問題になる。
Hotwire が強い理由は魔法ではなく、
ブラウザに「忘れる仕事」を返したことです。
ときに最もスケールする設計は、
「どう覚えるか」ではなく
「どう忘れさせるか」 なのかもしれません 🌱
