個人開発アプリ『ユメハシ』v2.1.0 — Firestore 無料枠を先延ばしするためにやった4つのこと
この記事のまとめ
「無料枠を超えてから対応する」って、けっこう怖くないですか?
私はけっこう怖くて、夜中に通知の音で起きそうな気がしてしまいます。
そこで、個人開発の Flutter Web アプリ ユメハシ(YumeHashi) では、まだ余裕があるうちに無料枠の寿命を延ばす施策 を v2.1.0 に入れました。月額 0 円の運用は今のところ続いていて、コスト変動要因は Firestore の書き込み回数だけ、DAU 3,000 人くらいまでは無料枠に収まる試算です。
ただ、伸ばせるなら伸ばしておきたい。心理的な余裕、欲しいですよね。
この記事で紹介する施策は 4 つです。
- JSON のインデント削除(コンパクト形式)
- 書き込みデバウンスを 3 秒 → 5 秒に拡大
- gzip 圧縮 + Base64 + format バージョニング(後方互換あり)
- ドキュメントサイズの監視と警告
どれも、既存ユーザーの動作にもセキュリティにも影響しない形で実装しています。
前提: ユメハシの同期アーキテクチャ
施策の話に入る前に、構造をひとことだけ。
ユメハシはユーザーごとに 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 リポジトリ
関連記事
- ユメハシの技術スタックと 5 つの実装課題 — 起動速度改善・データ肥大化対策・CI レース条件など
- 『ユメログ』から『ユメハシ』へ — 夢と現実のあいだに、橋を架ける — アプリに込めた哲学
- AI 駆動開発 vs 従来開発の全工程比較 — 3 週間でアプリを作った実績データ