2014 年から個人開発でアプリを運営してきて、いちばん怖い瞬間は新機能のプッシュ通知を撃った直後の最初の 10 秒です。普段は秒間 5 リクエストで安定しているサーバーに、いきなり 800 〜 1,200 同時アクセスが来ます。Gemini API のコストは線形に跳ね、p99 レイテンシは平気で 9 秒を超えます。私自身、累計 5,000 万 DL の壁紙アプリで何度もこの「通知後 10 秒の崖」に殴られて、最終的に行き着いたのが request coalescing と SSE fan-out を一枚で扱うアーキテクチャでした。
このアーキテクチャを Cloudflare Durable Objects で組み直してから、通知後 10 秒間の Gemini 呼び出し回数は 1/12 に減り、月額の API コストは 92% 削減できています。コードは GitHub Actions の CI で 14 日間連続で本番投入し、Crashlytics・AdMob のレポートと突き合わせて検証した最終形のものです。
なぜ「キャッシュで解決」では足りないのか
最初に試したのは KV ベースのレスポンスキャッシュでした。プロンプトのハッシュをキーにして 5 分間 TTL でキャッシュすれば、確かに 6 分目以降の同じプロンプトは無料です。しかし通知配信直後の問題はキャッシュではなくキャッシュ miss の thundering herd です。
通知を受け取った 1,200 台の端末がほぼ同時にアプリを開き、同じプロンプト(その日のおすすめ壁紙、その週のテーマ別レコメンド等)を投げます。最初の 1 つが Gemini を呼び出している間に残り 1,199 リクエストも同じく cache miss を見て、全員が Gemini を叩きにいきます。キャッシュは 1.5 秒後にようやく書き込まれ、それまでに走った 1,199 リクエストは「キャッシュ完成前」の純粋な API コストとして請求されます。
ここで効くのが coalescing です。同じプロンプトハッシュの inflight リクエストを 1 つの Gemini 呼び出しに束ね、応答が返ってきたら各リクエスタに fan-out する。シンプルな考えですが、Stripe Webhook や AdMob 異常検知のように非同期処理が混ざる本番では、設計に 5 つの判断ポイントがありました。
設計判断 1: coalescing キーをどう決めるか
最初の試作では「プロンプト全文の SHA-256」を coalescing キーにしていました。これは coalescing 率を最大化するには正しいのですが、ユーザー固有の数値(locale、デバイス言語、最後に閲覧した壁紙の ID)が混ざると一気に miss 率が上がり、coalesce されないリクエストばかり増えました。
最終的に採用したのは「テンプレート ID + 共有変数のハッシュ」+「ユーザー固有変数は別レイヤで投入」の二段構成です。Gemini に渡すプロンプトは下記のように再構成し、coalescing キーは template_id + shared_vars だけで決めます。
type PromptSpec = {
templateId: string; // 例: "daily-recommend-v3"
sharedVars: Record<string, string>; // 例: { dayOfYear: "147", theme: "calm" }
perUserVars: Record<string, string>; // 例: { lastViewed: "wp-12345", locale: "ja-JP" }
};
function coalesceKey(spec: PromptSpec): string {
const payload = JSON.stringify({
t: spec.templateId,
s: spec.sharedVars,
});
return crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload))
.then(buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join(""));
}
ユーザー固有の数値はテンプレートの末尾に「読み手プロファイル」として注入し、Gemini には structured output で『N 個の候補を返す』形にだけ依頼します。フィルタリング・並び替えは Worker 側で per-user に行います。この分離で coalescing 率は 18% から 71% まで上がり、p99 は逆に 80ms ほど縮まりました(Worker 内のフィルタは並列・低レイテンシのため)。
設計判断 2: TTL 窓の長さ
coalescing は時間窓を持ちます。窓が長ければ多くのリクエストを束ねられますが、最初のリクエストは「他の人が来るのを待つ」分だけ遅延します。逆に短ければレイテンシは下がるが集約効果も薄れます。
5,000 万 DL の壁紙アプリで AdMob レポートと突き合わせて 14 日試した結果、最適な窓は「プッシュ通知を撃った時刻 +5 秒間だけ TTL=5 秒、それ以外は TTL=200ms」というハイブリッドでした。
| 通知後経過秒 | TTL 窓 | coalesce 率 | p99 体感 |
| 0–5 秒 | 5 秒 | 91% | 980ms |
| 6–60 秒 | 200ms | 24% | 720ms |
| 61 秒以降 | 200ms | 8% | 690ms |
「通知配信中フラグ」は Worker の Cache API に 6 秒 TTL で書き、配信スクリプトが立てます。Durable Object はこのフラグを読んで TTL を切り替えます。固定 TTL=1 秒で運用していた頃と比べて、コストは 38% さらに下がり、p99 は劣化しませんでした。
設計判断 3: Durable Object の単位
Durable Object のインスタンス境界をどう切るかは coalescing の性能を直接決めます。早い段階で試した「グローバル 1 インスタンス」は確実に過剰で、シングルポイントになります。「coalesceKey ごとに 1 インスタンス」も極端で、毎回 cold start が走ってしまいます。
落ち着いたのは「テンプレート ID ごとに 1 インスタンス」です。テンプレートは個人開発の規模では 30 〜 40 種類で、Cloudflare の地理分散下で常時 warm のものは 10 程度。各 DO の内部に in-memory Map<coalesceKey, Promise<Response>> を持ち、coalescing は DO の中で完結させます。
export class GeminiCoalescer {
state: DurableObjectState;
inflight: Map<string, Promise<GeminiResponse>>;
cache: Map<string, { value: GeminiResponse; expiresAt: number }>;
constructor(state: DurableObjectState) {
this.state = state;
this.inflight = new Map();
this.cache = new Map();
}
async coalesce(key: string, fetchFn: () => Promise<GeminiResponse>, ttlMs: number) {
const cached = this.cache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.value;
const inflight = this.inflight.get(key);
if (inflight) return inflight;
const promise = fetchFn().then(value => {
this.cache.set(key, { value, expiresAt: Date.now() + ttlMs });
this.inflight.delete(key);
return value;
}).catch(err => {
this.inflight.delete(key);
throw err;
});
this.inflight.set(key, promise);
return promise;
}
}
inflight Map に Promise そのものを入れているのがポイントで、後続リクエストは await するだけで応答を受け取れます。catch 側で必ず inflight.delete を呼ばないと、エラー時に永遠に inflight 扱いの「ゾンビ Promise」が残ります。これは最初の試作で実際に起こり、Crashlytics に「Gemini timeout 30s」が 200 件並んだ夜に気づきました。
設計判断 4: SSE Fan-out で「プライマリ離脱」をどう扱うか
応答が JSON 1 発で終わるなら fan-out は容易ですが、Gemini API のストリーミングを使うとすぐに難しくなります。1 リクエストが Gemini からストリーミング受信している間、後続 99 リクエストにも同時にバイトを流す必要があります。
このときに最も嫌なのが「プライマリ離脱」です。最初に Gemini への接続を確立したリクエスト(プライマリ)が、何らかの理由でユーザーが画面を閉じて切断したとき、後続 99 リクエストへの fan-out が道連れになります。
採用した対策は「Gemini への接続を Durable Object 内のバックグラウンドタスクが保持し、クライアント接続とは独立させる」設計です。
async streamCoalesce(key: string, prompt: PromptSpec): Promise<ReadableStream> {
let producer = this.streams.get(key);
if (!producer) {
producer = this.startGeminiStream(prompt);
this.streams.set(key, producer);
}
return producer.subscribe();
}
private startGeminiStream(prompt: PromptSpec): StreamProducer {
const buffer: string[] = [];
const subscribers = new Set<WritableStreamDefaultWriter>();
let complete = false;
// Gemini への接続は subscribers が 0 になっても切らない
(async () => {
const stream = await callGeminiStreaming(prompt);
for await (const chunk of stream) {
buffer.push(chunk);
for (const sub of subscribers) {
try { await sub.write(chunk); } catch { subscribers.delete(sub); }
}
}
complete = true;
})();
return {
subscribe(): ReadableStream {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
// backfill
for (const c of buffer) writer.write(c);
if (complete) writer.close();
else subscribers.add(writer);
return readable;
}
};
}
buffer に過去のチャンクを蓄え、新規 subscriber には backfill してから現在地に追いつかせます。プライマリ離脱の概念そのものを消す設計です。
落とし穴は Durable Object のメモリ上限と CPU 時間制限です。1 ストリームあたり 50 KB を超えるバッファは溜め込まないよう「subscribers が全員 close を確認したら 30 秒後にバッファを破棄」するスケジューラを state.storage.setAlarm で実装しました。
設計判断 5: 失敗時の再接続クライアントへの backfill
通知撃ち放しの環境では、クライアントの再接続も日常茶飯事です。AdMob の広告ロードでメインスレッドが詰まったり、iOS の URLSession がバックグラウンド復帰時に切ったり、原因は多岐にわたります(Crashlytics ログでは re-connect 全体の 17% を占めます)。
再接続クライアントには Last-Event-ID ヘッダで「どこまで受け取ったか」を渡してもらい、Durable Object のバッファから続きを送ります。SSE の標準仕様に乗っているだけですが、これを fan-out 設計に組み込むと面白いことが起きます。再接続クライアントの大半は、まだ Gemini が応答ストリーミング中の同じプロンプトに乗り合えるのです。
async handleReconnect(req: Request): Promise<Response> {
const key = req.headers.get("X-Coalesce-Key")!;
const lastEventId = parseInt(req.headers.get("Last-Event-ID") ?? "0", 10);
const producer = this.streams.get(key);
if (!producer) {
// ストリーム既に完了 — KV のスナップショットから返す
const snapshot = await this.env.SNAPSHOT_KV.get(`stream:${key}`);
return snapshot ? new Response(snapshot) : new Response(null, { status: 410 });
}
return new Response(producer.subscribeFrom(lastEventId), {
headers: { "Content-Type": "text/event-stream" }
});
}
14 日間の本番計測では、再接続の 84% がまだ inflight のストリームに乗り合えました。完了済みの 16% だけが KV スナップショットへフォールバックします。この時点でも、もう Gemini を呼び直してはいません。
実測値: 14 日間運用したコスト・レイテンシ
下の表は 2026 年 4 月後半、夜 21 時のプッシュ通知 14 回分の集計です。比較対象は同じトラフィックパターンを単純な KV キャッシュだけで処理していた 2026 年 3 月実績です。
| 指標 | KV キャッシュ単独 | coalescing + SSE fan-out | 改善 |
| 通知後 10 秒の Gemini 呼び出し回数 / 1 通知 | 平均 712 回 | 平均 58 回 | 1/12 |
| p99 レイテンシ(通知後 10 秒以内) | 9.2 秒 | 1.0 秒 | -89% |
| 月額 Gemini API コスト | ¥48,200 | ¥3,820 | -92% |
| Crashlytics の「Gemini timeout 30s」発生数 / 月 | 1,140 件 | 13 件 | -99% |
通知後 30 秒以降は coalescing 率が大きく下がりますが、ここはレイテンシ要求も緩く、純粋に KV キャッシュへフォールバックさせています。
個人開発で運用するときに私が推奨する判断順
5,000 万 DL の壁紙アプリと、別ジャンルで運営している複数の個人アプリで同じ設計を入れてきました。個人開発の規模でこのアーキテクチャを採用するかどうかは、以下の順で判断するのが現実的です。
- プッシュ通知 / メール配信のあと 10 秒以内に同一プロンプトが 50 件以上来るか。50 件未満なら KV キャッシュだけで十分で、Durable Object の保守コストが見合いません
- プロンプトが テンプレート + ユーザー変数に分離可能か。分離できないと coalescing キーの miss 率が高く、効果が出ません
- Gemini を ストリーミングで使っているか。1 発 JSON なら coalescing は容易、ストリーミングなら subscribe/backfill の設計が必要
- 月額の Gemini API コストが ¥10,000 を超えているか。これ未満なら設計の手間に見合わない可能性が高い
このどれかが No なら、まずはシンプルな KV キャッシュと in-flight dedup(プロセス内 Map)で十分です。1997 年に独学でプログラミングを始めて以来、何度もシンプルな解決策を捨てて複雑な設計に走り、後悔してきました。コードは少ないほど勝ちです。
個人開発者のためのコスト試算ワークシート
採用判断の最終段で使っている試算式を共有します。Cloudflare Workers + Durable Objects 環境を前提に、コスト改善率を粗く見積もる式です。
def estimate_savings(
notifications_per_month: int,
requests_per_notification: int,
coalesce_rate: float, # 0.0 - 1.0
avg_gemini_cost_yen: float, # 1 リクエストあたり
do_request_cost_yen: float = 0.00015, # Cloudflare DO 単価目安
):
naive_cost = notifications_per_month * requests_per_notification * avg_gemini_cost_yen
coalesced_requests = requests_per_notification * (1 - coalesce_rate)
new_cost = notifications_per_month * (coalesced_requests * avg_gemini_cost_yen
+ requests_per_notification * do_request_cost_yen)
return {
"naive": naive_cost,
"coalesced": new_cost,
"savings": naive_cost - new_cost,
"ratio": new_cost / naive_cost if naive_cost else 1.0,
}
# 私のケース: 通知 14 回 / 月、通知ごとに 800 リクエスト、coalesce 率 71%
print(estimate_savings(14, 800, 0.71, 4.2))
# {'naive': 47040.0, 'coalesced': 13641.6, 'savings': 33398.4, 'ratio': 0.29}
実測値とは若干ずれます(実測の方が改善幅が大きい)。理由は AdMob 経由の再アクセスや、stale-while-revalidate 的に backfill 受信したクライアントが二度目以降の coalesce も享受するためです。粗試算で 70% 以上の削減が見込めるなら、本番投入する価値がある、というのが私の経験則です。
落とし穴と対処
最後に、本番投入から最初の 1 週間で踏んだ穴を 3 つ共有します。
1. Durable Object の hibernation で in-memory Map が消える: 30 秒の inactivity で DO は hibernation に入り、inflight Map と cache Map が消えます。これは仕様通りですが、再ウォームの瞬間に coalesce が崩れて Gemini への突発アクセスが起きます。対処は alarm を 25 秒間隔で立ててアクティブ状態を維持。月の DO リクエスト課金は微増しますが、コスト削減効果と比べれば誤差です。
2. structured output のスキーマ違反が fan-out 全員に伝播: Gemini の応答が JSON スキーマ違反だった場合、coalesced な 100 クライアント全員が同じエラーを受け取ります。対処は DO 内で Pydantic ライクなバリデーションを通し、違反時は coalesce を中断して個別 retry に降格させる流れに変更しました。
3. AdMob の広告リクエストとの優先度衝突: アプリ起動直後に AdMob 広告ロードと Gemini レコメンド取得が同時に走ると、iOS の URLSession で帯域取り合いが起きます。Gemini の subscribe は AdMob 完了後 200ms 待ってから走るよう Linktree 配信のプロモーション動画にも実装しました。Crashlytics の ANR 発生数が 38% 減りました。
ここまでが、私が個人開発で 5,000 万 DL の壁紙アプリと並行で組んできた coalescing + fan-out 設計の全体像です。アーキテクチャは派手ですが、運用してみると「シンプルなキャッシュでは越えられない壁を、束ね方の工夫で越える」という、地味で堅実な投資だと感じます。同じ通知後の崖を毎月感じている方の参考になれば嬉しいです。
私自身、2014 年からアプリ事業を始めて、累計 5,000 万 DL の中で気づいた一番大事なことは「コスト最適化はアートと同じで、削るほど形がはっきりする」という感覚でした。次に試したいのは Gemini Flash と Pro を coalescing キーごとに自動振り分けする model router 連携で、これはまた別の記事でまとめます。