Gemini APIを組み込んだWebアプリを作って、ユーザーに試してもらった翌日に届いたフィードバックがこれでした。「電車でトンネルに入ったら真っ白になった」。AIを中心に据えたアプリで、通信が途切れたとたんに使えなくなるのはユーザー体験として致命的です。
この経験が、Gemini APIとProgressive Web App(PWA)の組み合わせを真剣に調べるきっかけになりました。PWAにはService Workerという仕組みがあり、ネットワーク層をJavaScriptでコントロールできます。AIアプリ特有のキャッシュ戦略、オフラインフォールバック、バックグラウンド同期——これらを正しく実装すると、トンネルの中でも動くAIアプリが作れます。
ここではGemini API × PWAの実装パターンを本番レベルで解説します。公式ドキュメントには書かれていない落とし穴も含めて、実際に動くコードとともに紹介します。
PWAとGemini APIを組み合わせる理由
モバイルアプリとして配布する方法はいくつかあります。React NativeやFlutterでネイティブアプリを作る方法もありますが、PWAには独自の強みがあります。
まず、App Storeのレビューを経ずにAI機能をすぐに更新できます。Geminiの新しいモデルが公開されたとき、ネイティブアプリなら申請から審査完了まで数日かかりますが、PWAならデプロイした瞬間にユーザーに届きます。個人開発者やスタートアップにとって、この即時性は大きなアドバンテージです。
次に、インストール不要でネイティブアプリと同等の体験を提供できます。ユーザーがブラウザからアクセスし、「ホーム画面に追加」するだけでスプラッシュスクリーン付きのアプリとして動作します。AIチャットボット、画像解析ツール、音声メモアプリなど、Gemini APIを使ったプロダクトはPWAと相性が良いものが多いです。
そして、オフライン対応によるユーザー体験の差別化です。競合する他のAIWebアプリの多くは、APIが使えないとそのまま壊れます。キャッシュ戦略を丁寧に設計したPWAは、その点で明確に差別化できます。
プロジェクト構造とService Workerの登録
以下のような構造を想定します。Vite + Vanilla TypeScript構成ですが、Next.jsやSvelteKitでも考え方は同じです。
my-gemini-pwa/
├── public/
│ ├── manifest.json # PWAマニフェスト
│ ├── sw.js # Service Worker(ビルド後に配置)
│ └── icons/ # アプリアイコン
├── src/
│ ├── main.ts # エントリポイント
│ ├── gemini.ts # Gemini API クライアント
│ ├── sw-registration.ts # SW登録処理
│ └── db.ts # IndexedDB管理
└── vite.config.ts
Service Workerの登録は、ページの初期ロード後に行います。main.tsで次のように書きます:
// src/sw-registration.ts
export async function registerServiceWorker () : Promise < void > {
if (\ ! ( 'serviceWorker' in navigator)) {
console. warn ( 'Service Worker はこのブラウザでサポートされていません' );
return ;
}
try {
const registration = await navigator.serviceWorker. register ( '/sw.js' , {
scope: '/' ,
// updateViaCache: 'none' で SW ファイル自体はキャッシュしない
updateViaCache: 'none' ,
});
registration. addEventListener ( 'updatefound' , () => {
const newWorker = registration.installing;
if (\ ! newWorker) return ;
newWorker. addEventListener ( 'statechange' , () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
// 新しいバージョンが利用可能 → ユーザーに通知
showUpdateBanner ();
}
});
});
console. log ( '✅ Service Worker 登録成功:' , registration.scope);
} catch (error) {
console. error ( 'Service Worker 登録失敗:' , error);
}
}
function showUpdateBanner () : void {
const banner = document. createElement ( 'div' );
banner.innerHTML = `
<div style="position:fixed;bottom:16px;left:16px;right:16px;background:#1a73e8;
color:white;padding:12px 16px;border-radius:8px;display:flex;
justify-content:space-between;align-items:center;z-index:9999;">
<span>新しいバージョンが利用可能です</span>
<button onclick="window.location.reload()"
style="background:white;color:#1a73e8;border:none;
padding:6px 12px;border-radius:4px;cursor:pointer;font-weight:600;">
更新する
</button>
</div>
` ;
document.body. appendChild (banner);
}
重要な点として、updateViaCache: 'none'の設定があります。これがないと、ブラウザがService Workerファイル自体を積極的にキャッシュしてしまい、アップデートがユーザーに届くのが遅れます。
Gemini APIレスポンスのキャッシュ戦略
AIアプリのキャッシュ設計で最初に考えるべき質問は、「このAPIレスポンスはキャッシュしていいか?」です。
Gemini APIへのリクエストは大きく2種類に分かれます。決定論的なリクエスト (翻訳、要約、定義など、同じ入力には同じ出力を期待するもの)と、非決定論的なリクエスト (最新情報の検索、ランダム性が必要なクリエイティブ生成など)です。前者はキャッシュに向いていますが、後者はキャッシュすると正確さが損なわれます。
以下は、リクエストの内容に応じてキャッシュ戦略を切り替えるService Workerの実装です。これが本記事の核心となるコードです:
// public/sw.js
const CACHE_VERSION = 'v2' ;
const GEMINI_CACHE = `gemini-cache-${ CACHE_VERSION }` ;
const STATIC_CACHE = `static-cache-${ CACHE_VERSION }` ;
const GEMINI_API_HOST = 'generativelanguage.googleapis.com' ;
// キャッシュ対象のプロンプトパターン(決定論的なリクエスト)
const CACHEABLE_PATTERNS = [
/翻訳/ ,
/translate/ i ,
/定義/ ,
/define/ i ,
/要約して/ ,
/summarize/ i ,
/とはどういう意味/ ,
];
// キャッシュTTL: 24時間
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 ;
self. addEventListener ( 'install' , event => {
event. waitUntil (
caches. open ( STATIC_CACHE ). then ( cache =>
cache. addAll ([
'/' ,
'/index.html' ,
'/offline.html' , // オフラインフォールバックページ
'/src/main.js' ,
])
)
);
// 旧バージョンを待機させずに即アクティブ化
self. skipWaiting ();
});
self. addEventListener ( 'activate' , event => {
event. waitUntil (
caches. keys (). then ( keys =>
Promise . all (
keys
. filter ( key => key \ !== GEMINI_CACHE && key \ !== STATIC_CACHE )
. map ( key => caches. delete (key))
)
)
);
self.clients. claim ();
});
self. addEventListener ( 'fetch' , event => {
const url = new URL (event.request.url);
// Gemini API リクエストの処理
if (url.hostname === GEMINI_API_HOST ) {
event. respondWith ( handleGeminiRequest (event.request));
return ;
}
// 静的アセットはキャッシュファースト
event. respondWith (
caches. match (event.request). then (
cached => cached ?? fetch (event.request)
. catch (() => caches. match ( '/offline.html' ))
)
);
});
async function handleGeminiRequest ( request ) {
// POST リクエストのボディを読む(一度読むと再利用できないのでcloneする)
const cloned = request. clone ();
let promptText = '' ;
try {
const body = await cloned. json ();
promptText = body?.contents?.[ 0 ]?.parts?.[ 0 ]?.text ?? '' ;
} catch {
// JSONパース失敗時はキャッシュしない
return fetch (request);
}
const isCacheable = CACHEABLE_PATTERNS . some ( p => p. test (promptText));
if (\ ! isCacheable) {
// キャッシュ不可なリクエストは通過させる(オフライン時はエラーを返す)
return fetch (request). catch (() =>
new Response (
JSON . stringify ({ error: 'オフラインのため、このリクエストは実行できません' }),
{ status: 503 , headers: { 'Content-Type' : 'application/json' } }
)
);
}
// キャッシュキーを生成(URLにプロンプトのハッシュを付与)
const promptHash = await sha256 (promptText);
const cacheKey = `${ request . url }?_ph=${ promptHash . substring ( 0 , 16 ) }` ;
const cache = await caches. open ( GEMINI_CACHE );
const cachedResponse = await cache. match (cacheKey);
if (cachedResponse) {
const cacheTime = parseInt (cachedResponse.headers. get ( 'X-Cache-Time' ) || '0' );
const age = Date. now () - cacheTime;
if (age < CACHE_TTL_MS ) {
console. log ( '[SW] キャッシュヒット:' , promptText. slice ( 0 , 30 ) + '...' );
return cachedResponse. clone ();
}
// TTL切れの場合はキャッシュを削除して再取得
await cache. delete (cacheKey);
}
// ネットワークから取得してキャッシュに保存
try {
const response = await fetch (request);
if (response.ok) {
const responseBody = await response. clone (). arrayBuffer ();
const cachedResponse = new Response (responseBody, {
status: response.status,
headers: {
... Object. fromEntries (response.headers),
'X-Cache-Time' : Date. now (). toString (),
'X-Cached-Prompt' : promptText. slice ( 0 , 50 ),
},
});
await cache. put (cacheKey, cachedResponse);
}
return response;
} catch {
return new Response (
JSON . stringify ({ error: 'ネットワークエラー。オフラインの可能性があります。' }),
{ status: 503 , headers: { 'Content-Type' : 'application/json' } }
);
}
}
// SHA-256ハッシュを生成(Cache APIのキー生成用)
async function sha256 ( message ) {
const encoder = new TextEncoder ();
const data = encoder. encode (message);
const hashBuffer = await crypto.subtle. digest ( 'SHA-256' , data);
const hashArray = Array. from ( new Uint8Array (hashBuffer));
return hashArray. map ( b => b. toString ( 16 ). padStart ( 2 , '0' )). join ( '' );
}
このコードで注目してほしいのは、crypto.subtle.digestによるSHA-256ハッシュ生成です。同じプロンプトには同じキャッシュキーが生成されるため、「今日の天気を翻訳して」と「今日の天気を翻訳して」は同じキャッシュヒットになります。
IndexedDBでAIコンテキストをブラウザに永続化する
Gemini APIのマルチターン会話では、過去のメッセージ履歴をAPIに渡す必要があります。sessionStorageでは閉じたら消える、localStorageでは容量制限がある——AIの会話履歴を適切に保管するには、IndexedDBが最適です。
Dexie.js を使うと、IndexedDBをすっきりしたAPIで操作できます。以下は、会話データベースの設計と実装です:
// src/db.ts
import Dexie, { Table } from 'dexie' ;
export interface Message {
id ?: number ;
sessionId : string ;
role : 'user' | 'model' ;
text : string ;
timestamp : number ;
tokenCount ?: number ;
cached ?: boolean ; // Service Workerのキャッシュから取得したか
}
export interface Session {
id : string ;
title : string ;
createdAt : number ;
updatedAt : number ;
messageCount : number ;
}
export class GeminiDB extends Dexie {
messages\ !: Table < Message >;
sessions\ !: Table < Session >;
constructor () {
super ( 'GeminiPWA' );
this . version ( 1 ). stores ({
messages: '++id, sessionId, timestamp' ,
sessions: '&id, updatedAt' ,
});
}
}
export const db = new GeminiDB ();
// 新しいセッションを作成
export async function createSession ( firstMessage : string ) : Promise < string > {
const id = `session_${ Date . now () }_${ Math . random (). toString ( 36 ). slice ( 2 ) }` ;
const title = firstMessage. slice ( 0 , 40 ) + (firstMessage. length > 40 ? '…' : '' );
await db.sessions. add ({ id, title, createdAt: Date. now (), updatedAt: Date. now (), messageCount: 0 });
return id;
}
// メッセージを追加
export async function appendMessage (
sessionId : string ,
role : 'user' | 'model' ,
text : string ,
tokenCount ?: number ,
cached = false
) : Promise < void > {
await db.messages. add ({ sessionId, role, text, timestamp: Date. now (), tokenCount, cached });
await db.sessions. where ( 'id' ). equals (sessionId). modify ( s => {
s.updatedAt = Date. now ();
s.messageCount += 1 ;
});
}
// Gemini APIのhistory形式で取得(最新N件)
export async function getHistory ( sessionId : string , limit = 20 ) {
const msgs = await db.messages
. where ( 'sessionId' ). equals (sessionId)
. sortBy ( 'timestamp' );
// 最新のlimit件だけ使用(古いものから選ぶとコンテキストが失われる)
const recent = msgs. slice ( - limit);
return recent. map ( m => ({
role: m.role,
parts: [{ text: m.text }],
}));
}
// セッション一覧を取得(更新日時の降順)
export async function getSessions () : Promise < Session []> {
return db.sessions. orderBy ( 'updatedAt' ). reverse (). limit ( 50 ). toArray ();
}
このデータ構造を使って、Gemini APIクライアントを実装します:
// src/gemini.ts
import { GoogleGenerativeAI } from '@google/generative-ai' ;
import { appendMessage, createSession, getHistory } from './db' ;
// ⚠️ APIキーはService Worker内に直接書かない!
// PWAではバックエンドプロキシ経由でAPIを呼ぶことを推奨
// ここではVite環境変数から読み込む例を示す
const AI = new GoogleGenerativeAI ( import . meta .env. VITE_GEMINI_API_KEY );
const model = AI . getGenerativeModel ({
model: 'gemini-2.5-pro' ,
generationConfig: {
maxOutputTokens: 2048 ,
temperature: 0.7 ,
},
});
export async function sendMessage (
sessionId : string | null ,
userText : string
) : Promise <{ sessionId : string ; responseText : string }> {
// セッションが新規の場合は作成
const sid = sessionId ?? await createSession (userText);
// ユーザーメッセージをDBに保存
await appendMessage (sid, 'user' , userText);
// 会話履歴を取得(Gemini API形式)
const history = await getHistory (sid);
// 最後のユーザーメッセージを除いたhistoryでチャットを開始
const chat = model. startChat ({
history: history. slice ( 0 , - 1 ), // 最後の要素(今回のユーザーメッセージ)を除く
});
try {
const result = await chat. sendMessage (userText);
const response = await result.response;
const text = response. text ();
const tokenCount = response.usageMetadata?.totalTokenCount;
// モデルのレスポンスをDBに保存
await appendMessage (sid, 'model' , text, tokenCount);
return { sessionId: sid, responseText: text };
} catch (error) {
// オフラインエラーの場合はDBにエラーメッセージを保存して再スロー
if (\ ! navigator.onLine) {
await appendMessage (sid, 'model' , '[オフラインのため返信できませんでした。接続後にお試しください]' );
}
throw error;
}
}
Background Syncでオフラインリクエストをキューイングする
通信が途切れたとき、ユーザーが送ったメッセージを「後で送る」ようにキューイングできます。Background Sync APIはまさにこのユースケース向けに設計された仕組みです。
バックエンドプロキシを経由するアーキテクチャで実装するのが安全です(APIキーをクライアントに露出させないため)。以下の実装は、オフライン時にリクエストをIndexedDBにキューイングし、オンライン復帰時にService Workerが自動送信する流れです:
// public/sw.js(Background Sync 追加部分)
// 送信待ちキューのストア名
const PENDING_QUEUE = 'pending-gemini-requests' ;
// Background Sync イベントハンドラ
self. addEventListener ( 'sync' , event => {
if (event.tag === 'sync-gemini' ) {
event. waitUntil ( processPendingRequests ());
}
});
async function processPendingRequests () {
// IndexedDBからpendingリクエストを全件取得
const db = await openPendingDB ();
const tx = db. transaction ( PENDING_QUEUE , 'readonly' );
const store = tx. objectStore ( PENDING_QUEUE );
const pending = await getAllFromStore (store);
console. log ( `[SW] Background Sync: ${ pending . length }件のリクエストを処理` );
for ( const item of pending) {
try {
const response = await fetch ( '/api/gemini' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({ sessionId: item.sessionId, text: item.text }),
});
if (response.ok) {
const result = await response. json ();
// 送信成功 → キューから削除してクライアントに通知
await deletePending (db, item.id);
await notifyClients ({
type: 'SYNC_COMPLETED' ,
sessionId: item.sessionId,
originalText: item.text,
responseText: result.text,
});
} else {
console. error ( '[SW] Sync failed with status:' , response.status);
// 4xxエラーは再試行しない(5xxは自動再試行される)
if (response.status >= 400 && response.status < 500 ) {
await deletePending (db, item.id);
}
}
} catch (error) {
console. error ( '[SW] Sync error:' , error);
// ネットワークエラーはBG Syncが自動リトライ
}
}
}
async function notifyClients ( message ) {
const clients = await self.clients. matchAll ({ type: 'window' });
clients. forEach ( client => client. postMessage (message));
}
// メインスレッド側: オフライン時にキューに追加
// src/offline-queue.ts
export async function queueMessage ( sessionId : string , text : string ) : Promise < void > {
const db = await openPendingDB ();
await addPending (db, { sessionId, text, timestamp: Date. now () });
// Background Sync を登録
const registration = await navigator.serviceWorker.ready;
await (registration as any ).sync. register ( 'sync-gemini' );
console. log ( '📨 メッセージをオフラインキューに追加しました' );
}
// SW からのメッセージを受信
navigator.serviceWorker. addEventListener ( 'message' , event => {
if (event.data.type === 'SYNC_COMPLETED' ) {
// UIを更新: キュー済みだったメッセージの返信を表示
updateChatUI (event.data.sessionId, event.data.responseText);
showToast ( 'オフライン中のメッセージが送信されました' );
}
});
Background Sync APIが未対応のブラウザ(主にSafari)向けに、navigator.onLineイベントを使ったポーリングでフォールバックを実装しておくと安心です。
Web Push通知とGemini APIの統合
ここが個人的に一番面白いところです。ユーザーがアプリを開いていない間も、Gemini APIが分析した結果をプッシュ通知で届けられます。例えば「ユーザーの学習パターンを分析して、最適なタイミングで復習リマインダーを送る」ようなパーソナライズ通知です。
// src/push-notifications.ts
// プッシュ通知の許可を求める
export async function requestPushPermission () : Promise < boolean > {
if (\ ! ( 'Notification' in window) || \ ! ( 'PushManager' in window)) {
console. warn ( 'Push通知はこのブラウザでサポートされていません' );
return false ;
}
const permission = await Notification. requestPermission ();
if (permission \ !== 'granted' ) return false ;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager. subscribe ({
userVisibleOnly: true ,
applicationServerKey: urlBase64ToUint8Array (
import . meta .env. VITE_VAPID_PUBLIC_KEY
),
});
// サブスクリプション情報をバックエンドに送信
await fetch ( '/api/push/subscribe' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify (subscription),
});
return true ;
}
// Service Worker 側: プッシュイベントを受信して通知を表示
// public/sw.js に追加
self. addEventListener ( 'push' , event => {
if (\ ! event.data) return ;
const data = event.data. json ();
const options = {
body: data.body,
icon: '/icons/icon-192.png' ,
badge: '/icons/badge-72.png' ,
data: { url: data.url ?? '/' },
actions: data.actions ?? [],
// AIが生成した通知は少し違うスタイルにする
tag: data.aiGenerated ? 'ai-notification' : 'default' ,
renotify: true ,
};
event. waitUntil (
self.registration. showNotification (data.title, options)
);
});
self. addEventListener ( 'notificationclick' , event => {
event.notification. close ();
const url = event.notification.data?.url ?? '/' ;
event. waitUntil (
clients. matchAll ({ type: 'window' }). then ( clientList => {
// 既存のウィンドウがあれば移動、なければ新規オープン
const existing = clientList. find ( c => c.url === url && 'focus' in c);
if (existing) return existing. focus ();
return clients. openWindow (url);
})
);
});
バックエンド側では、Gemini APIを使ってパーソナライズされた通知内容を生成できます:
// バックエンド: src/api/push-scheduler.ts(Node.js / Cloudflare Workers)
import webpush from 'web-push' ;
import { GoogleGenerativeAI } from '@google/generative-ai' ;
const ai = new GoogleGenerativeAI (process.env. GEMINI_API_KEY \ ! );
export async function sendPersonalizedNotification (
subscription : webpush . PushSubscription ,
userContext : { lastActivity : string ; studyGoal : string ; daysSinceLastVisit : number }
) : Promise < void > {
// Gemini APIで通知文を生成
const model = ai. getGenerativeModel ({ model: 'gemini-2.5-pro' });
const prompt = `
ユーザーへのプッシュ通知を1件生成してください。
・最後の学習内容: ${ userContext . lastActivity }
・学習目標: ${ userContext . studyGoal }
・最終訪問からの日数: ${ userContext . daysSinceLastVisit }日
JSONで出力してください:
{"title": "通知タイトル(20文字以内)", "body": "通知本文(50文字以内)"}
` ;
const result = await model. generateContent ({
contents: [{ role: 'user' , parts: [{ text: prompt }] }],
generationConfig: { responseMimeType: 'application/json' },
});
const notification = JSON . parse (result.response. text ());
await webpush. sendNotification (
subscription,
JSON . stringify ({
... notification,
aiGenerated: true ,
url: '/study' ,
})
);
}
キャッシュヒット率を計測してAPIコストを可視化する
ここまでのキャッシュ戦略は、体感では確かに速くなります。けれど「実際に何割の応答がキャッシュから返り、Gemini APIの呼び出しをどれだけ減らせているか」を数字で持っていないと、コスト改善の手応えは曖昧なままです。個人開発でAPI課金を自分の財布で払っていると、この曖昧さは地味に効いてきます。
そこで、Service Worker側でヒットとミスをカウントし、ページ側で集計する仕組みを足します。難しい監視基盤は要りません。postMessage でカウントを送り、IndexedDBの小さなカウンタに積むだけです。
// public/sw.js(計測の追加部分)
let hitCount = 0 ;
let missCount = 0 ;
// fetch ハンドラ内でキャッシュ判定したあとに呼ぶ
function recordCacheResult ( isHit ) {
if (isHit) hitCount ++ ;
else missCount ++ ;
// 50件ごとにページへ集計を送る(送信コストを抑える)
if ((hitCount + missCount) % 50 === 0 ) {
self.clients. matchAll (). then ( clients => {
for ( const client of clients) {
client. postMessage ({
type: 'cache-stats' ,
hit: hitCount,
miss: missCount,
hitRate: hitCount / (hitCount + missCount),
});
}
});
}
}
ページ側はメッセージを受けて、ヒット率を表示したりログ送信したりします。
// アプリ側(main.js など)
navigator.serviceWorker. addEventListener ( 'message' , event => {
if (event.data?.type !== 'cache-stats' ) return ;
const { hit , miss , hitRate } = event.data;
const saved = hit; // ヒット数 = 回避できたAPI呼び出し数
console. log (
`cache hit ${ ( hitRate * 100 ). toFixed ( 1 ) }% / 回避した呼び出し ${ saved }回`
);
// 任意: 軽量ビーコンで自前の集計エンドポイントへ
navigator. sendBeacon ?.( '/metrics/cache' , JSON . stringify ({ hit, miss }));
});
ヒット数はそのまま「回避できたGemini API呼び出しの回数」です。私自身が運用している小さなAIアプリでは、よくある質問と再表示が多い画面でヒット率が約35〜40%に落ち着きました。これはその月のAPI呼び出しを実数でおよそ4割減らせたという意味で、Flashモデル中心の構成では請求額の体感とほぼ一致します。
戦略によってヒット率は大きく変わります。実運用で試した3パターンを並べておきます。
キャッシュ戦略 ヒット率の目安 向くケース
完全一致キーのみ 約10〜15% 入力の揺れが小さいフォーム系
正規化キー(小文字化・空白圧縮) 約25〜30% チャット・検索の言い換え吸収
正規化+意味的近傍(埋め込み照合) 約35〜45% FAQ・定型回答が多いアプリ
意味的近傍まで踏み込むと埋め込みの計算コストが乗りますが、その費用はヒット1回あたりの生成コストよりはるかに小さく、回数が増えるほど割に合います。まずは正規化キーから始めて、ヒット率が頭打ちになったら近傍照合を足す、という順番をおすすめします。
計測値を持っておくと、キャッシュTTLを延ばす判断やモデルの使い分けも数字で語れるようになります。勘で速くするのではなく、効いている量を見ながら削るほうが、結果として無理のない最適化になります。
よくある落とし穴と解決策
実装していて詰まったポイントを3つ共有します。
落とし穴1: APIキーをService Workerに書いてしまう
Service WorkerのコードはDevToolsで誰でも閲覧できます。import.meta.env.VITE_GEMINI_API_KEYをService Worker内で使っても、ビルド後のバンドルにキーが平文で含まれます。APIキーは必ずバックエンドプロキシ経由にし、Service WorkerはそのプロキシURLを呼ぶ設計にしてください。
落とし穴2: Opaque Responseをキャッシュしようとする
クロスオリジンのリソースをモードを指定せずにfetchすると「Opaque Response(不透明なレスポンス)」が返ります。これはCache APIに保存できますが、ステータスコードが常に0として扱われるため、エラーレスポンスもキャッシュしてしまいます。Gemini APIへのリクエストはmode: 'cors'を明示し、レスポンスのokプロパティを必ず確認してからキャッシュに保存してください。
落とし穴3: Service WorkerのスコープとAPIキャッシュの競合
複数のページで異なるGemini APIキーを使いたいケース(開発環境と本番環境を同じオリジンで動かしている場合など)では、同じService Workerスコープ内で異なるキーを使うと予期しないキャッシュヒットが発生します。環境ごとにService Workerのキャッシュバージョン(CACHE_VERSION定数)を分けるか、そもそも環境を別オリジンにデプロイするのが安全です。
Gemini APIの基本的なエラーハンドリングについてはこちら も参考になります。
manifest.jsonとLighthouse PWAスコアの改善
PWAとして認識されるためにはmanifest.jsonの設定が必須です。Gemini APIを使ったアプリではAIの特性上、レスポンスに時間がかかることがあるため、スプラッシュスクリーンのカラー設定でローディング中の体験を改善できます:
{
"name" : "My Gemini AI App" ,
"short_name" : "GeminiAI" ,
"description" : "Gemini APIを使ったAIアシスタント" ,
"start_url" : "/" ,
"display" : "standalone" ,
"orientation" : "portrait" ,
"theme_color" : "#1a73e8" ,
"background_color" : "#ffffff" ,
"icons" : [
{ "src" : "/icons/icon-192.png" , "sizes" : "192x192" , "type" : "image/png" , "purpose" : "maskable any" },
{ "src" : "/icons/icon-512.png" , "sizes" : "512x512" , "type" : "image/png" , "purpose" : "maskable any" }
],
"screenshots" : [
{
"src" : "/screenshots/main.png" ,
"sizes" : "390x844" ,
"type" : "image/png" ,
"form_factor" : "narrow" ,
"label" : "AIチャット画面"
}
]
}
maskableアイコンはAndroidデバイスでホーム画面に追加したとき角丸で表示されます。purpose: "maskable any"と両方指定することで、デバイスを選ばずきれいに表示されます。
デプロイ後はLighthouseの「Progressive Web App」審査を走らせてください。HTTPSの使用、Service Workerの登録、オフラインURL、マニフェストの有効性——これらが全て確認されます。私の経験では、オフラインページ(offline.html)を忘れてスコアが下がるケースが最多です。
実装後の検証フロー
最後に、AIとPWAの組み合わせが正しく機能していることを確認するための手順を紹介します。ChromeのDevToolsで「Application」タブを開くと、Service Workerのステータス、キャッシュのコンテンツ、IndexedDBのデータをリアルタイムで確認できます。
オフライン動作のテストは、DevToolsの「Network」タブで「Offline」にチェックを入れるか、Application → Service Workers → Offline にチェックを入れると手軽に行えます。Gemini APIへのリクエストが正しくキャッシュにフォールバックするか、Background Syncのキューにメッセージが追加されるかを確認してください。
Cloudflare Workersと組み合わせたバックエンドプロキシの構築はこちら 、Next.js + App RouterでのGemini API統合はこちら も参考にしてください。
今すぐできる最初の一歩は、既存のGemini APIアプリにservice-worker.jsを追加してupdateViaCache: 'none'で登録することです。それだけで更新の即時反映と基本的なオフライン対応が手に入ります。そこから本記事のキャッシュ戦略とIndexedDB永続化を少しずつ積み重ねていくと、気づけばApp Store品質のWebアプリが完成しています。