個人開発アプリ『ユメハシ』v2.1.0 — Firestore 無料枠を先延ばしするためにやった4つのこと

この記事のまとめ

「無料枠を超えてから対応する」って、けっこう怖くないですか?

私はけっこう怖くて、夜中に通知の音で起きそうな気がしてしまいます。

そこで、個人開発の Flutter Web アプリ ユメハシ(YumeHashi) では、まだ余裕があるうちに無料枠の寿命を延ばす施策 を v2.1.0 に入れました。月額 0 円の運用は今のところ続いていて、コスト変動要因は Firestore の書き込み回数だけ、DAU 3,000 人くらいまでは無料枠に収まる試算です。

ただ、伸ばせるなら伸ばしておきたい。心理的な余裕、欲しいですよね。

この記事で紹介する施策は 4 つです。

  1. JSON のインデント削除(コンパクト形式)
  2. 書き込みデバウンスを 3 秒 → 5 秒に拡大
  3. gzip 圧縮 + Base64 + format バージョニング(後方互換あり)
  4. ドキュメントサイズの監視と警告

どれも、既存ユーザーの動作にもセキュリティにも影響しない形で実装しています。


前提: ユメハシの同期アーキテクチャ

施策の話に入る前に、構造をひとことだけ。

ユメハシはユーザーごとに 1 Firestore ドキュメント を持ち、その中に全データ(夢・目標・タスク・書籍・活動ログ・通知)を JSON として詰めてアップロードする設計です。読み書きはブラウザ内 SQLite が主で、Firestore は「全量 JSON のバックアップ」という位置付けです。

[ブラウザ内 SQLite (WASM)]  ←→  [users/{uid}] (Firestore)
        ↑ マスター                       ↑ バックアップ

Firestore の書き込みは「起動時タイムスタンプ比較」「データ変更時のデバウンス後」「アプリ離脱時の未同期データ」の 3 種類だけ。書き込み回数を増やさない設計 が、土台になっています。


課題認識: Firestore の課金軸を整理する

課金軸無料枠の目安
書き込み20,000 docs / 日
ストレージ1 GiB
下り帯域10 GiB / 月

ここで気を付けたいのは、書き込みは「操作回数」で課金され、ドキュメントサイズには依存しない という点です。1 KB の書き込みでも 100 KB の書き込みでも、課金上は同じ「1 write」です。

つまり 書き込み回数を減らすのが本丸 。ただし、Firestore には「1 ドキュメント = 最大 1 MiB」という硬い上限もあるので、サイズ削減もサボれません。

「枚数」と「重さ」の両方を見る、というのが今回の方針です。


施策 1: JSON のインデント削除(コンパクト形式)

クラウド同期用のエクスポートに json.encode(data) を使い、インデントを省略します。整形済みエクスポートはローカルファイル書き出し専用として残しました。

「人間が読むファイル」と「マシンが読むファイル」を分けただけ、と言えばそれだけです。

効果: サイズ 約 20% 削減


施策 2: 書き込みデバウンスを 3 秒 → 5 秒に拡大

static const _debounceDuration = Duration(seconds: 5); // 3秒から変更

たった 1 行の変更ですが、効きます。

連続編集シナリオ(4〜5 個のタスクを次々編集)で、2〜3 回の書き込みが 1 回に収まる確率が上がります。ローカル DB は即座に書き込まれるため、デバウンス中にタブを閉じてもデータは失われません。

「失われない」という保証が裏にあるから、思い切って遅らせられる、というところがポイントだと感じています。

効果: 書き込み回数 約 30〜40% 削減


施策 3: gzip 圧縮 + Base64 + format バージョニング

ここが今回いちばん試行錯誤した部分でした。

Firestore の data フィールドに保存する JSON 文字列を gzip で圧縮 して Base64 にエンコードします。Base64 はサイズを約 1.33 倍に膨らませますが、gzip の圧縮率(繰り返しの多いテキストで 3〜5 倍)の方が圧倒的に大きく、トータルでは大きく縮みます。

問題は、すでに使ってくれているユーザーをどう扱うか でした。フォーマットを切り替えた瞬間に既存データが読めなくなったら、それは事故です。

対策として、プレフィックスによる format バージョニング を入れました。

const String syncPayloadFormat2Prefix = 'gz1:';

String decodeSyncPayload(String payload) {
  if (!payload.startsWith(syncPayloadFormat2Prefix)) {
    return payload; // legacy (format 1) プレーン JSON をそのまま返す
  }
  final base64Body = payload.substring(syncPayloadFormat2Prefix.length);
  final compressed = base64Decode(base64Body);
  final decompressed = GZipDecoder().decodeBytes(compressed);
  return utf8.decode(decompressed);
}
  • gz1: プレフィックスで format 2(圧縮形式)を識別
  • JSON の先頭は必ず {[ なので gz1: と衝突しない
  • 既存ユーザーは次回の書き込みタイミングで自動的に format 2 へ移行

地味ですが、「移行しない移行」 という気持ちで設計しました。利用者目線では、何も起きないのが正解です。

なお、圧縮・Base64 はどちらも可逆エンコーディングであり暗号化ではありません。機密性は従来通り Firestore Security Rules と HTTPS で担保されます。「サイズの話」と「秘密の話」は別、と切り分けて考えるのがおすすめです。

効果: インデント削除と合わせると 元の 1/4〜1/5 に縮小。


施策 4: ドキュメントサイズ監視と警告

アップロード前にペイロードサイズをチェックし、900 KB を超えたら debugPrint で警告します。v2.1.0 で既に導入している「30 日で既読通知・完了タスクを物理削除」と組み合わせれば、通常の使い方で 1 MiB を超えることはまずありません。

「壁にぶつかってから対処する」よりも、「壁の手前で声をかけてくれる仕組み」 を作っておく方が、運用は楽になります。


想定効果と今後

数字でまとめると、こうなります。

削減率
同期ドキュメントサイズ−70〜80%
書き込み回数−30〜40%
下り帯域−70〜80%

Firestore の無料枠 DAU 3,000 人の閾値が、さらに先延ばしされる想定です。「あと 2〜3 倍スケールしても無料のまま戦える」のは、精神的にめちゃくちゃ大きな余裕につながります。

次にやれそうな打ち手は 起動時の Firestore 読み取りを間引く あたりですが、マルチデバイスユーザーの体感速度に影響する可能性があるので、運用で必要になってから手を付ける予定です。「先に最適化しすぎない」というのも、個人開発を続けるコツだと感じています。


まとめ

  • Firestore のコスト最適化は「書き込み回数の削減」と「ドキュメントサイズの削減」の 2 軸
  • v2.1.0 では JSON インデント削除 + デバウンス 5 秒 + gzip 圧縮 + サイズ監視 の 4 点セット
  • 既存ユーザーへの影響をゼロにするため、format バージョニング で後方互換性を確保
  • 「無料枠を超えてから対応する」のではなく、今のうちに延命する ほうが心理的な負担が少ない

個人開発で同じようにサーバーレス × 無料枠運用をしている方の、ヒントの 1 つにでもなれば嬉しいです。

アプリ: ユメハシ(YumeHashi)を試す リポジトリ: GitHub リポジトリ

関連記事

お問い合わせ

ご質問やフィードバックなど、お気軽にお問い合わせください。

お問い合わせはこちら