概要
個人開発のSaaS(タスク管理ツール)で、Supabaseの無料〜低価格帯プランのEgress(帯域)上限を実際に超過する事故が発生しました。原因はよくあるアンチパターンで、同じ設計をしていると誰でも踏む可能性があるため、原因と対処法を共有します。
何が起きたか
postgres_changesでチケット・コメント・アクティビティログ等のテーブルをRealtime購読し、変更イベントを受け取ったら「安全側に倒して」プロジェクトの全データをrefreshData()で再取得する、という実装にしていました。
// 問題があった実装のイメージ
supabase
.channel(`project-tickets-${projectId}`)
.on('postgres_changes', { event: '*', schema: 'public', table: 'tickets' }, () => {
refreshData(); // ← 変更内容を見ずに、プロジェクト全件を再取得していた
})
.subscribe();
一見「変更があったら最新化する」という素直な実装に見えますが、これには重大な問題があります。
誰か1人がチケットを1件編集するたびに、その瞬間そのプロジェクトを開いている全員のブラウザが、プロジェクトの全チケット(数千件規模だと数MB)を再ダウンロードするという設計になっていました。実運用が始まり複数人が同時に開いた状態でチケット編集が繰り返されると、Egressが継続的に消費され続け、月間上限を超過しました。
根本原因
postgres_changesイベントのペイロードには、変更された行そのもの(payload.new / payload.old)が含まれています。にもかかわらず、それを使わずに全件再取得していたのが根本原因です。「変更があった」という通知だけを使って、内容を見ずに全件フェッチし直すのは典型的なアンチパターンです。
対処法: ペイロードを直接ローカルstateに適用する
修正後は、postgres_changesが返す変更行を直接ローカルのstateへマージする方式に変更しました。
// 修正後のイメージ
supabase
.channel(`project-tickets-${projectId}`)
.on('postgres_changes', { event: '*', schema: 'public', table: 'tickets' }, (payload) => {
if (payload.eventType === 'INSERT' || payload.eventType === 'UPDATE') {
setTickets((prev) => {
const idx = prev.findIndex((t) => t.id === payload.new.id);
if (idx === -1) return [...prev, enrich(payload.new)];
const next = [...prev];
next[idx] = enrich(payload.new);
return next;
});
} else if (payload.eventType === 'DELETE') {
setTickets((prev) => prev.filter((t) => t.id !== payload.old.id));
}
})
.subscribe();
これにより、変更のたびにネットワーク往復(全件再取得)が発生しなくなり、Egress消費が変更行のサイズ相当まで縮小しました。
ペイロードの形が想定外(テーブル定義変更直後など)だった場合に備えて、パース失敗時だけ従来の全件取得にフォールバックするようにしてあります。差分適用ロジックにバグを作り込むリスクよりも、フォールバックを用意しておく方が安全という判断です。
ついでに見つかった副次的な問題
修正のついでに監査したところ、通知テーブルのRealtime購読にfilterが付いておらず、全ユーザー分の通知変更イベントを、ログイン中の全クライアントが受信していたことも判明しました。
// Before: 誰の通知でも受信してしまう
.on('postgres_changes', { event: '*', schema: 'public', table: 'notifications' }, handler)
// After: 自分宛てのみに限定
.on('postgres_changes', { event: '*', schema: 'public', table: 'notifications', filter: `user_id=eq.${userId}` }, handler)
postgres_changesはfilterを付けなければテーブル全体のイベントがそのまま流れてくるため、「自分に関係あるデータだけ購読しているつもりが、実は全件購読していた」というケースは意外と見落としやすいポイントです。
まとめ・チェックリスト
Supabase Realtimeを使っていて帯域コストが気になる場合、以下を確認すると良いと思います。
-
postgres_changesのハンドラー内で、内容を見ずに全件再取得(SELECT *相当)をしていないか -
ペイロード(
payload.new/payload.old)を使ってローカルstateを直接更新できないか -
各購読に
filterが付いており、本当に必要な行だけを受信しているか - 複数クライアントが同時に開く運用を想定したとき、1回の変更が何倍のトラフィックに増幅されるか試算したか
「変更があったら安全に全部取り直す」は一見安全に見えて、実運用のスケールでは危険なパターンだと実感しました。同じような構成でSupabase Realtimeを使っている方の参考になれば幸いです。