Hotwire は Rails に「SPA の操作感」を持ち込んだ。
これは間違いなく革新だ。
しかし、SPA の複雑さまで一緒に持ち込む必要はない。
むしろ Hotwire を「魔法」だと思って雑に使えば、SPA より重く、デバッグしづらく、スケールしない構成になる。
この記事では、
- チュートリアルでよく見る Hotwire の書き方が
- なぜプロダクション環境(高負荷時)で破綻するのか
- そして どう設計すべきか
を、アンチパターン中心に整理する。

1️⃣ よくある Hotwire の最初の一歩(そして罠)
# app/controllers/products_controller.rb
def index
@products = Product.all
end<%= turbo_frame_tag "products" do %>
<%= render @products %>
<% end %>動く。
速い。
シンプル。
だが、Product.all は静かにプロダクトを殺す。
なぜ危険か(Lighthouse の警告を無視するな)
❌ DOM ノード数の爆発
Lighthouse は **「1ページあたり DOM ノード数 1,500 未満」**を推奨している。
商品カード 1 件が 20〜30 ノードだとすると、100 件で簡単に上限を超える。
Turbo は Morphdom によって差分更新を行うが、
大量の DOM 比較と置換はブラウザのメインスレッドを確実に塞ぐ。
❌ ペイロードの肥大化
HTML は JSON より表現力が高いが、その分サイズも大きい。
数百〜数千件のレコードを HTML で一気に送れば、数 MB 単位の転送量になる。
👉 問題は「遅い」ことではない。
👉 負荷が線形ではなく、ある時点で急激に崩れることだ。

✅ 改善例:Frame は常に Bounded であるべき
ページネーションは必須だ。
そして 書き方を間違えないことも重要。
# Controller
@products = Product.page(params[:page]).per(20) # 常に上限を設ける <%= turbo_frame_tag "products" do %>
<%= render @products %>
<%= paginate @products %>
<% end %>remote: trueは不要- Turbo は通常の
<a>タグを自動でインターセプトする
👉 Frame は「小さなページ」だと考えろ。
2️⃣ 「非 HTML フォーマットはどうする?」という分岐点
def index
@products = Product.all
respond_to do |format|
format.html
format.json { render json: @products }
end
endこのコードを書いた瞬間、設計の地獄が始まる。
Hotwire(HTML over the Wire)と JSON API を
1 つのコントローラで混在させると、必ず歪む。
起きる問題
- 責務の混合
- HTML 用の eager load
- JSON 用の serializer / field 制御
→ 最適化方針が衝突する
- キャッシュ戦略の崩壊
- ETag / Last-Modified の管理が複雑化
- フォーマットごとに無駄な再生成が発生
👉 鉄則
Hotwire を選んだ Controller は HTML に特化させろ。
API が必要なら Api::V1::ProductsController を分けて作る。
3️⃣ リアルタイム更新という名の「自己 DDoS」
チュートリアルでよく見る
**「モデルのコールバックで broadcast」**は、最大級のアンチパターンだ。
❌ モデルへの副作用埋め込み
class Post < ApplicationRecord
after_create_commit { broadcast_append_to "posts" }
end Rails らしく見えるが、大規模アプリでは凶器になる。
なぜ危険か
- バッチ処理・コンソール操作・テストデータ投入
→ すべてが WebSocket をトリガー - モデルは
「誰に」「いつ」「どの UI に」出すかを判断できない
❌ 全ユーザーへの一斉送信
ActionCable.server.broadcast("global_feed", ...) 1 人の投稿が、
10,000 人のオンラインユーザー全員に配信される。
- Redis Pub/Sub の帯域消費
- Action Cable サーバーの I/O wait 増大
- Puma のスレッドが WebSocket 書き込みで埋まる
👉 典型的な Thundering Herd 問題
👉 線形ではなく、一気に死ぬ
✅ 改善例:Service Object + 非同期 + 間引き
UI への副作用は モデルではなくユースケース層に置く。
# app/services/create_post_service.rb class
CreatePostService
def call(params, user)
post = Post.new(params.merge(user: user))
if post.save # UI 更新が必要な場合のみ、明示的に
BroadcastNewPostJob.perform_later(post)
end
post
end
end 👉 教訓
Turbo Streams は「DB の鏡」ではない。
「UI への手紙」だ。送る相手とタイミングを選べ。

4️⃣ Fragment Caching × Turbo は「必須科目」
Hotwire アプリが遅くなる原因の多くは、
サーバーサイドの HTML レンダリング時間だ。
SPA はクライアント CPU を使う。
Hotwire は サーバー CPU を使う。
つまり、
キャッシュなしの Hotwire は、高負荷時に確実に詰まる。
<%= render partial: "posts/post",
collection: @posts,
cached: true %> - コレクションキャッシュは必須
- Turbo Frame のキャッシュキーには注意
- 関連モデル更新時は
touch: trueを適切に設定
📊 Real Data: パフォーマンスの現実
| 処理 | コスト感 | 補足 |
|---|---|---|
| 単純な HTML render | 5〜20ms | ERB + 文字列結合 |
render_to_string(partial) | 20〜50ms | 呼び出し回数で線形増加 |
| JSON API render | 2〜10ms | 軽量 serializer 前提 |
| Broadcast(1 user) | + 数十 ms | Job + Redis publish |
| Broadcast(10k users) | 数秒〜Timeout | Action Cable 側が詰まる |
👉 broadcast × partial × 多人数 = 即アウト
結論
Hotwire は「速い」のではない。
「初期ロード体験が良い」だけだ。
サーバー負荷は、
設計を誤ると SPA より高くなる。
だからこそ重要なのは、
- キャッシュ
- ページネーション
- 非同期 Job 化
- 責務分離
という、古典的だが本質的な Rails のチューニングだ。
🤔 命題
Hotwire は素晴らしい。
だが、それは **「正しく使えば」**の話だ。
何十万、何百万のユーザーが
Turbo Streams や Action Cable を通じて
リアルタイム更新を受け取る世界を想像してほしい。
そのたびに、
- broadcast
- partial render
- WebSocket 書き込み
が発生する。
最適化されていなければ、
それは UX 改善ではなく、サーバーへの負債になる。
スケーラビリティとパフォーマンスを維持するために、
Turbo Streams の設計・キャッシュ戦略を含めた最適化は必須だ。

他にも実運用での知見があれば、ぜひコメントで共有してほしい。
