Firebase無料枠だけで"リアルタイム存在確認アプリ"を運用する設計と実装
この記事の対象読者
- Firebaseで個人開発アプリを月額0円で運用したい人
- Firestore と Realtime Database の使い分けに悩んでいる人
- PWAで「ネイティブアプリっぽい体験」を実現したい人
TL;DR
- Firestore の書き込み課金を避けるため、プレゼンス管理を Realtime Database に分離した
-
onDisconnectフックで サーバーレスな死活監視 を実現した - CSS
@keyframesによる弾性振動で、framer-motion なしでSpring Physics風アニメーションを実装した - Web Audio API で通知音をコード生成し、バンドルサイズを0にした
- 結果として Firebase Spark プラン(無料枠)で安定運用中
作ったもの
離れて暮らす家族の「存在確認」に特化したPWAアプリ 「Haptics」 を開発しました。
画面上に浮かぶ光(=他のユーザー)をタップするだけで、相手に音とアニメーションが届くシンプルなアプリです。メッセージ機能は意図的に実装していません。
🔗 https://www.haptics.akirakano.work/
技術構成
React + TypeScript + Vite
├── Firebase Hosting(CDN配信・SSL自動)
├── Cloud Firestore(永続データ)
├── Realtime Database(プレゼンス管理)
├── Cloud Functions(見守りメール通知)
├── Web Audio API(通知音生成)
└── Tailwind CSS v4(UI)
以降、各技術要素でハマったポイントと解決策を詳しく書きます。
1. 【設計】Firestore と Realtime Database を併用してコストを抑える
課題
リアルタイムアプリでは「この人は今オンラインか?」というプレゼンス情報が必要です。しかし、この情報をFirestoreで管理すると 読み書き課金が爆発 します。
例えば、10人のユーザーが各自のプレゼンスを10秒ごとに更新し、全員の状態を監視する場合:
書き込み: 10人 × 6回/分 × 60分 = 3,600回/時
読み取り: 10人 × 10人 × 6回/分 × 60分 = 36,000回/時
Firestoreの無料枠は1日あたり書き込み20,000回・読み取り50,000回なので、たった10人でも数時間で枯渇します。
解決策:Realtime Database に分離
Realtime Databaseは同時接続数ベースの課金であり、書き込み・読み取り自体にはほぼ課金されません(帯域・ストレージ上限はある)。プレゼンス情報のように「頻繁に更新される一時的なデータ」に最適です。
- Realtime Database: プレゼンス(オンライン状態)── 頻繁に更新・切断時に自動削除
- Cloud Firestore: ユーザー情報・タップ履歴 ── 永続的に保持するデータ
実装: onDisconnect による自動クリーンアップ
Realtime Database の onDisconnect は、クライアントの接続が切れた時にサーバー側で自動実行されるフックです。これによりサーバーレスで正確なプレゼンス管理が可能になります。
import { ref, set, onDisconnect, onValue } from 'firebase/database';
export class RTDBPresenceRepository {
async setPresence(user: User): Promise<void> {
const presenceRef = ref(this.db, `presence/${user.id}`);
// 切断時に自動削除(サーバー側で実行される)
await onDisconnect(presenceRef).remove();
// プレゼンス情報を書き込み
await set(presenceRef, {
name: user.name,
iconConfig: user.iconConfig,
iconScale: user.iconScale,
lastActive: Date.now()
});
}
// リアルタイムで全ユーザーのプレゼンスを監視
subscribeToPresence(callback: (users: User[]) => void): () => void {
const presenceRef = ref(this.db, 'presence');
const unsubscribe = onValue(presenceRef, (snapshot) => {
const users: User[] = [];
snapshot.forEach((child) => {
users.push(this.snapshotToUser(child));
});
callback(users);
});
return unsubscribe;
}
}
ハマりポイント: onDisconnect は set より先に呼ぶ必要があります。set の後に onDisconnect を設定すると、その間にネットワークが切れた場合にゾンビデータが残ります。
2. 【実装】Web Audio API で通知音をコード生成する
課題
タップ受信時に心地よい通知音を鳴らしたい。しかし、音声ファイルを使うと:
- バンドルサイズが増える
- ライセンス管理が面倒
- 音の微調整がしにくい
解決策:Web Audio API でリアルタイム生成
class SoundService {
private audioCtx: AudioContext | null = null;
// iOS対応:ユーザージェスチャーのタイミングで初期化
initOnGesture(): void {
if (!this.audioCtx) {
this.audioCtx = new AudioContext();
}
}
playPikon(): void {
if (!this.audioCtx) return;
const ctx = this.audioCtx;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
// 高音から低音へスライドする「ピコン」
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(
440, ctx.currentTime + 0.15
);
// フェードアウト
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01, ctx.currentTime + 0.2
);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.2);
}
}
ハマりポイント (iOS): iOSのSafariでは、AudioContext の生成をユーザージェスチャー(タップ等)のイベントハンドラ内で行わないと音が鳴りません。アプリ起動直後の初期化では動作しないため、「タップして開始」画面を設け、最初のタップイベント内で new AudioContext() を呼んでいます。
3. 【実装】CSSだけでSpring Physics風アニメーションを実現する
課題
タップ受信時にアイコンが「ボヨン」と弾むSpring Physicsアニメーションを実装したい。
framer-motion で失敗した話
最初は framer-motion の useAnimation + spring トランジションで実装しました。
// ❌ 動かなかったコード
controls.start({
scale: [1, 1.5, 1],
transition: { type: "spring", stiffness: 300, damping: 8 }
});
コンソール上はアニメーション完了と表示されるのに、画面上では一切変化が見えない。
原因は、framer-motion の spring トランジションは 2点間の補間専用 であり、[1, 1.5, 1] のようなキーフレーム配列を渡すと正しく動作しないためでした。公式ドキュメントにも明記されていますが、見落としやすいポイントです。
解決策:CSS @keyframes で手動実装
framer-motion を除去し、CSSアニメーションで減衰振動を手動で描きました。
@keyframes spring-pop {
0% { transform: scale(1); }
15% { transform: scale(1.4); } /* 最大膨張 */
30% { transform: scale(0.9); } /* 反動で収縮 */
45% { transform: scale(1.2); } /* 二次膨張 */
60% { transform: scale(0.95); } /* 二次収縮 */
75% { transform: scale(1.05); } /* 減衰 */
100% { transform: scale(1); } /* 元に戻る */
}
React側では key プロパティを変更してコンポーネントを再マウントし、CSSアニメーションを再トリガーしています。
const [popKey, setPopKey] = useState(0);
const handleTap = () => {
setPopKey(prev => prev + 1); // key変更で再マウント → アニメーション再生
};
return (
<div
key={`pop-${popKey}`}
style={{ animation: popKey > 0 ? 'spring-pop 0.5s ease-out' : 'none' }}
>
<UserIcon />
</div>
);
結果: framer-motion を除去したことでバンドルサイズが約120KB削減(891KB → 772KB)されるという副次的効果もありました。ライブラリの抽象化が合わない場合、生のCSS/JSに戻る判断も大切です。
4. 【設計】PWA最適化のTips
Wake Lock API で常時表示
Hapticsは「リビングに置いて眺める」使い方を想定しているため、画面スリープを防止しています。
// Wake Lock の取得
let wakeLock: WakeLockSentinel | null = null;
async function requestWakeLock() {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
// バックグラウンド復帰時に再取得
requestWakeLock();
});
} catch (err) {
console.log('Wake Lock not supported');
}
}
注意: visibilitychange イベントでWake Lockが解除されるため、フォアグラウンドに戻った時に再取得するリスナーが必要です。
manifest.json の設定
{
"name": "Haptics",
"short_name": "Haptics",
"display": "standalone",
"start_url": "/app/",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"icons": [...]
}
display: "standalone" によりブラウザUIが非表示になり、ネイティブアプリのような全画面表示が実現できます。
5. 【運用】Firebase無料枠の消費量
1ヶ月運用した実績値です(アクティブユーザー数名の小規模運用)。
| リソース | 無料枠 | 実消費量 | 消費率 |
|---|---|---|---|
| Firestore 読み取り | 50,000/日 | ~2,000/日 | 4% |
| Firestore 書き込み | 20,000/日 | ~500/日 | 2.5% |
| RTDB 帯域 | 10GB/月 | ~50MB/月 | 0.5% |
| Hosting 帯域 | 10GB/月 | ~200MB/月 | 2% |
| Cloud Functions 呼び出し | 2M/月 | ~1,000/月 | 0.05% |
プレゼンスをRTDBに分離したことで、Firestoreの課金が劇的に抑えられているのがわかります。
まとめ
| 課題 | 解決策 |
|---|---|
| プレゼンス管理のコスト | Firestore → Realtime Database に分離 |
| オフライン検出 |
onDisconnect フックで自動削除 |
| 通知音のバンドルサイズ | Web Audio API でリアルタイム生成 |
| Spring Physicsアニメーション | CSS @keyframes で手動実装 |
| 画面スリープ | Wake Lock API |
| 全体の運用コスト | Firebase Sparkプラン(無料)で運用 |
個人開発において「無料で運用し続けられる」ことは、アプリの寿命に直結します。Firebase の2つのデータベースを適材適所で使い分けることで、リアルタイム性とコスト効率を両立できました。
🔗 Haptics: https://www.haptics.akirakano.work/
ソースコードやアーキテクチャに関するフィードバックがあれば、ぜひコメントください。