はじめに:バグは直った。請求は増えた
飲食店向けモバイルオーダー MasterOrder を個人開発しています。本記事は、第1本の設計思想の話から一歩踏み込み、実際に火を吹いたトラブルシューティング の記録です。
舞台は 固定QR(KITEI_QR)モード で、スタッフ画面が Firestore を直読する構成です。先に前提をはっきりさせます。
- 通常モード:ブラウザは REST + SSE のみ。Firestore には接続しません。
- 本記事の主舞台:
MASTERORDER_FIRESTORE_CLIENT_READ_STAFF_ENABLEDが有効な、スタッフ Firestore 直読パス(Phase 2 POC)。
ここで卓カードの合計金額が一瞬「¥0」と表示される UI バグを直したら、Firebase コンソールの読み取り数が目に見えて増えました。バグ駆除と API 節約の狭間で、かなり苦戦した話を書きます。
1. 直面した課題:注文がある卓が「0円」になる
スタッフ管理画面(固定QR / 卓グリッド表示)で、注文済みの卓なのに合計が一時的に 「¥0」 と出る不具合がありました。現場から見ると「売上が消えた」ように見える。これは許容できません。
原因を追うと、データの 到着順序 と 信頼できる情報源 がズレていました。
Firestore 直読だけでは totalAmount が 0 のまま
固定QR では、ブラウザが active_sessions を Firestore リスナーで購読し、卓カードをリアルタイム更新します。ところがセッション doc 上の totalAmount は、注文のたびに即座に揃うとは限りません。
実装側にも、はっきりコメントを残しています。
/**
* 卓カードの合計・経過時間は GET /sessions/active?includeTotals=true 必須。
* Firestore 直読だけでは totalAmount が 0 のままのことがある。
*/
サーバーには、注文作成・更新後に active_sessions へ合計を書き戻す FirestoreSessionTotalsSync があります。しかし UI はスナップショットを受け取った瞬間に描画するため、書き戻しより先に 0 円が画面に出る タイミングが存在します。空席マージ時のデフォルト値も totalAmount: 0 なので、余計に紛らわしい。
「正確さ優先」で入れた修正が、次の爆弾になった
0 円表示を潰すため、確実に正しい合計を得る REST API を積極的に叩く方針に切り替えました。
GET /sessions/active?shopId={id}&includeTotals=true
発火タイミングは、画面のセッション読込、手動更新に加え、タブ復帰(visibilitychange / pageshow)経由の restoreStaffRuntimeAfterReturn() → scheduleLoadSessions({ includeTotals: true }) など、かなり広めです。
UI バグは解消しました。 スタッフの信頼は戻った。ところが Firebase コンソールを開くと、同じような操作負荷なのに 読み取りがおおよそ 2〜3 倍 に増えているのが観測されました(※環境・店舗数・卓数で変動する観測値です。リポジトリに「70→200」のような固定記録はありません)。
「直したはずのバグの代償が、インフラ請求に回ってきた」――個人開発あるあるを、まさにやらかしていました。
2. なぜ読み取りが跳ね上がったのか:二重取得の正体
Firestore の読み取り課金は、クエリで返ってきたドキュメント数 に比例します。ドキュメント数 × 回数。シンプルで残酷です。
サーバー側:includeTotals=true の N+1
includeTotals=true を付けると、サーバー TableSessionController はアクティブセッション一覧を返す前に、セッションごとに注文サブコレクション(sessionOrders)を .get() して合計を再計算します。アクティブ卓が N 卓あれば、概ね N 回のサブコレクション読み取りが乗る構造です。
フロントはすでに active_sessions をリスナー購読しているのに、表示のたびにサーバー経由で注文一式を読み直す。リアルタイム doc と集計 API の役割が重なった「二重取得」 が起きていました。
フロント側:タブ復帰のたびに高コスト API
特に効いてきたのが、スタッフがスマホで別タブを見て戻ってくるパターンです。restoreStaffRuntimeAfterReturn() が includeTotals: true 付きの読込をスケジュールするため、「画面に戻っただけ」で高コスト API が走りやすい 状態でした。
バグ修正の意図は「正しい合計を見せること」。結果として「正しい合計のために毎回フル再計算」に寄りすぎていた、というのが分析の結論です。
3. 解決策:キャッシュファーストのハイブリッド UI
方針を 「API 待ちでから描画」から「キャッシュで即描画 → 必要なときだけ enrich」 に変えました。ここが第2段の核心です。
① 信頼ソースの優先順位を変える
staff-firestore-runtime-sdk.js の handleFirestoreSessionsSnapshot では、次の順序にしています。
- Firestore スナップショット(ローカルキャッシュ含む)を 一次情報として即反映
renderViewsNowで先に画面を更新- 背景で
bootstrapSessionTotals()→ API enrich - enrich 完了後に再度
renderViewsNowで差分を上書き
function handleFirestoreSessionsSnapshot(sessions, activeShopId, kiteiQrMode) {
applySessionCache(sessions, activeShopId);
if (kiteiQrMode) {
notifyTableSeatsReconciled(sessions);
callOpt(opts.renderViewsNow); // まず描画
var bootstrap = typeof opts.bootstrapSessionTotals === 'function'
? Promise.resolve(opts.bootstrapSessionTotals())
: Promise.resolve();
bootstrap.finally(function () {
callOpt(opts.renderViewsNow); // enrich 後に再描画
});
}
}
API の結果は enrichSessionsFromApiList() でキャッシュにマージし、detailsEnriched: true を付与。固定QR では卓メタ(REST)とセッション(Firestore)を mergeTableSeatsWithSessions() で結合し、合計だけ後から API で上書きするハイブリッド にしています。
これで「0 円の一瞬」は、キャッシュ上の前回値やセッション doc の暫定値で先に埋め、正確な合計は追いついたタイミングで更新、という UX に寄せられます。
② API 呼び出しの間引き(※「5秒デバウンス」ではない)
よくある誤解として「5秒間は同じセッションへの再リクエストを無視」といった話がありますが、この実装には存在しません。実際に効いているのは次のレイヤーです。
進行中リクエストの合流(enrichInflight)
if (enrichInflight && options.force !== true) {
return enrichInflight;
}
enrichInflight = staffSdk.getActiveSessions(shopId, { includeTotals: true })
同時に複数の enrich が走らないよう、進行中の Promise を返すだけ。force: true のときだけ再発行します。
| 仕組み | 値・挙動 | 役割 |
|---|---|---|
LOAD_SESSIONS_DEBOUNCE_MS |
1500ms | scheduleLoadSessions の連打抑制 |
renderViewsDebounced |
350〜400ms | 描画の間引き |
| SSE 後ダッシュボード再読込 | 60秒 | 別経路だが連打抑制の好例 |
enrichInflight |
進行中合流 | 並列 enrich の暴発防止 |
「5秒ガードを入れた」というより、レイヤーごとに適切な粒度で間引く 設計です。
4. エンジニアとしての学び
UI バグ修正は、インフラコストの設計変更になりうる
「正確さのために毎回サーバー再計算」は、Firestore 課金と相性が悪い。バグチケットを閉じた瞬間に、別の意味で 技術的負債 が増えることがあります。個人開発ほど、このトレードオフが直撃します。
リアルタイム doc と集計 API は、責務を分ける
- Firestore:セッションの存在・状態変化(いつ・どの卓がアクティブか)
- REST API(
includeTotals):注文サブコレクションに基づく正確な合計
両方「正しい」とは限らないタイミングがある以上、先にキャッシュで描画 → 必要時だけ enrich が現実解です。UX とコストの両立は、きれいな一本道ではなくハイブリッドになりがちです。
Firebase コンソールだけでは、店舗別の内訳が見えない
本番運用では、読み取り数に shopId タグを付けた自前メトリクスが欲しくなります。「どの店舗の、どの画面操作が doc を食っているか」が見えないと、最適化の効果検証も難しい。今回のように「2〜3 倍に増えた」は観測できても、「この変更で何 % 減ったか」は、計測を入れるまで断定できない ことも学びました。「コスト半分」といった数字は、単体修正の未計測値としては書けません。
5. まとめ:苦戦のあとに残った設計原則
固定QR スタッフ画面での一連の対応を振り返ると、次の原則に収束しました。
- 通常モード(REST + SSE)と Firestore 直読モードを混同しない ― 記事・設計・計測すべてで前提を分ける。
- 0 円バグは「データ未到着」問題 ― 毎回フル再計算ではなく、キャッシュファースト + 背景 enrich で解く。
includeTotals=trueは高コスト ― サーバー側 N+1 を意識し、発火タイミングを間引く。- デバウンスは秒数の伝言ゲームではない ― inflight 合流・1500ms・350ms など、レイヤーごとに選ぶ。
バグ駆除と API 節約は、どちらか一方を選ぶ話ではありませんでした。「現場が信頼できる表示」と「読み取り課金を抑える取得戦略」 を、同じ画面の中で両立させる作業だったと思います。
MasterOrder では引き続き、在庫まわりの読み取り最適化(別文脈で 1000 回/時 → 300 回/時のような改善)など、Firestore コスト全体の最適化も進めています。次の記事では、そのあたりも実装ベースで書ければと思います。