●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Gemini API × PWA Complete Implementation Guide — Service Workers, Offline AI, and Web Push Notifications for App Store-Quality Web Apps
Build production-grade AI web apps by combining Gemini API with Progressive Web App techniques — Service Worker caching, IndexedDB for AI context persistence, offline fallbacks, and Web Push notifications — plus instrumentation to surface API cost from your cache hit rate.
The feedback came in the morning after I let my first users try a Gemini-powered web app: "It went completely blank when my train went through a tunnel." For an AI-centric application, becoming completely unusable the moment connectivity drops is not just a bug — it's a product-ending experience.
That frustration pushed me to seriously explore the combination of Gemini API and Progressive Web Apps. PWAs give you a Service Worker — a JavaScript proxy that sits between your app and the network. With the right caching strategies, offline fallbacks, and background sync, you can build AI apps that keep working in a tunnel, on a plane, or anywhere else connectivity is unreliable.
This guide covers the full picture: caching Gemini API responses intelligently, persisting AI conversation context in IndexedDB, queuing offline requests with Background Sync, and triggering personalized Web Push notifications using Gemini as the decision engine. These aren't toy examples — everything here is production-tested code.
Why PWA + Gemini API Is Worth the Investment
You have options for distributing an AI-powered mobile experience: React Native, Flutter, Capacitor. PWA has a different set of trade-offs that make it particularly attractive for AI applications.
The most underrated advantage is deployment speed. When Gemini releases a new model or you want to update a prompt strategy, a PWA update is live the second you deploy. No App Store review, no waiting for users to update. Given how quickly the Gemini API ecosystem evolves, being able to iterate in minutes rather than days matters.
The second advantage is installability without friction. Users tap "Add to Home Screen" and your app launches with a splash screen, runs full-screen, and feels native — with zero App Store friction. For AI productivity tools, this dramatically reduces the barrier between a first visit and daily use.
The third advantage is what this article is actually about: the offline experience. Most AI web apps treat connectivity as a hard requirement. Service Workers turn that assumption on its head.
Project Structure and Service Worker Registration
The following examples use Vite + TypeScript, but the concepts apply equally to Next.js, SvelteKit, or any modern build setup.
Register the Service Worker after the page has loaded, and handle the update flow so users see a notification when a new version is available:
// src/sw-registration.tsexport async function registerServiceWorker(): Promise<void> { if (\!('serviceWorker' in navigator)) { console.warn('Service Workers not supported in this browser'); return; } try { const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/', // Prevent the browser from caching the SW file itself 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 registered:', registration.scope); } catch (error) { console.error('Service Worker registration failed:', 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>A new version is available</span> <button onclick="window.location.reload()" style="background:white;color:#1a73e8;border:none; padding:6px 12px;border-radius:4px;cursor:pointer;font-weight:600;"> Reload </button> </div> `; document.body.appendChild(banner);}
The updateViaCache: 'none' setting is easy to miss but important. Without it, the browser may aggressively cache the Service Worker file itself, delaying update delivery to users.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Instrument Service Worker hits/misses to see exactly how many Gemini API calls you avoid
✦Hit-rate and cost-saving benchmarks for three caching strategies (exact / normalized / semantic)
✦The order to layer offline support, IndexedDB persistence, and Background Sync at production quality
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Intelligent Caching Strategies for Gemini API Responses
The first question for any AI app caching strategy: "Should this response be cached at all?"
Gemini API requests split into two broad categories. Deterministic requests — translations, definitions, summaries, factual lookups — produce consistent results for the same input and are excellent cache candidates. Non-deterministic requests — creative generation, real-time data synthesis, user-specific personalization — should not be cached, as stale responses would be actively harmful.
Here's the core Service Worker implementation that routes requests based on this distinction:
The crypto.subtle.digest call is what makes this robust: the same prompt always generates the same cache key, regardless of how the URL is structured. This is preferable to using the request URL as a cache key, since Gemini API endpoints are fixed.
Persisting AI Conversation Context in IndexedDB
Multi-turn Gemini conversations require passing the full message history on every request. sessionStorage disappears on close, localStorage has a 5MB limit that fills quickly with conversation data — IndexedDB is the right tool here.
With Dexie.js, the IndexedDB API becomes clean and TypeScript-friendly:
Using this, a Gemini API client that maintains conversation context locally looks like this:
// src/gemini.tsimport { GoogleGenerativeAI } from '@google/generative-ai';import { appendMessage, createSession, getHistory } from './db';// ⚠️ In production, route through a backend proxy — never expose API keys client-sideconst 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); await appendMessage(sid, 'user', userText); const history = await getHistory(sid); const chat = model.startChat({ history: history.slice(0, -1), // Exclude the message we're about to send }); try { const result = await chat.sendMessage(userText); const response = await result.response; const text = response.text(); await appendMessage(sid, 'model', text, response.usageMetadata?.totalTokenCount); return { sessionId: sid, responseText: text }; } catch (error) { if (\!navigator.onLine) { await appendMessage(sid, 'model', '[Offline — response unavailable. Please retry when connected.]'); } throw error; }}
Background Sync for Offline Request Queuing
Background Sync lets you queue API requests when the user is offline and process them automatically when connectivity returns — without requiring the user to have your app open.
The architecture below uses a backend proxy (essential for keeping your API key server-side). Offline requests are stored in IndexedDB and synced by the Service Worker:
// public/sw.js — Background Sync additionself.addEventListener('sync', event => { if (event.tag === 'sync-gemini') { event.waitUntil(processPendingRequests()); }});async function processPendingRequests() { const db = await openPendingDB(); const pending = await db.getAll('pending-requests'); console.log(`[SW] Background Sync: processing ${pending.length} queued requests`); 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 db.delete('pending-requests', item.id); // Notify open tabs that the queued message was delivered const clients = await self.clients.matchAll({ type: 'window' }); clients.forEach(c => c.postMessage({ type: 'SYNC_COMPLETED', sessionId: item.sessionId, responseText: result.text, })); } else if (response.status >= 400 && response.status < 500) { // Client errors (bad request, auth) won't benefit from retry await db.delete('pending-requests', item.id); } // Server errors (5xx) are left in the queue for automatic retry } catch (error) { console.error('[SW] Sync error — will retry:', error); } }}// Main thread — queue a message when offline// src/offline-queue.tsexport async function queueMessage(sessionId: string, text: string): Promise<void> { const db = await openPendingDB(); await db.add('pending-requests', { sessionId, text, timestamp: Date.now() }); const registration = await navigator.serviceWorker.ready; await (registration as any).sync.register('sync-gemini');}// Listen for successful sync eventsnavigator.serviceWorker.addEventListener('message', event => { if (event.data?.type === 'SYNC_COMPLETED') { updateChatUI(event.data.sessionId, event.data.responseText); showToast('Your offline message was delivered'); }});
For browsers without Background Sync support (mainly Safari), add a window.addEventListener('online', ...) listener as a fallback that checks and processes the queue when connectivity is restored.
Web Push Notifications Powered by Gemini
This is where the combination gets genuinely interesting. With Web Push + Gemini API, your server can analyze user patterns and send notifications with AI-generated, personalized content at the right moment — without any user interaction required.
On the backend, Gemini generates the notification content itself:
// Backend: src/api/push-scheduler.tsimport webpush from 'web-push';import { GoogleGenerativeAI } from '@google/generative-ai';const ai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY\!);export async function sendAINotification( subscription: webpush.PushSubscription, userContext: { recentActivity: string; goal: string; daysSinceLastVisit: number }): Promise<void> { const model = ai.getGenerativeModel({ model: 'gemini-2.5-pro', generationConfig: { responseMimeType: 'application/json' }, }); const result = await model.generateContent(`Generate a personalized push notification for this user.Recent activity: ${userContext.recentActivity}Goal: ${userContext.goal}Days since last visit: ${userContext.daysSinceLastVisit}Respond with valid JSON only:{"title": "max 40 chars", "body": "max 80 chars", "url": "/"} `); const notification = JSON.parse(result.response.text()); await webpush.sendNotification( subscription, JSON.stringify({ ...notification, aiGenerated: true }) );}
Measuring Cache Hit Rate to Make API Cost Visible
The caching strategies above clearly feel faster. But unless you know what fraction of responses actually come from the cache — and how many Gemini API calls you're avoiding — the cost benefit stays vague. As an indie developer paying for API usage out of my own pocket, that vagueness quietly adds up.
So let's count hits and misses inside the Service Worker and aggregate them on the page. No heavy observability stack required — just postMessage the counts and tally them in a small IndexedDB counter.
// public/sw.js (instrumentation addition)let hitCount = 0;let missCount = 0;// Call this after deciding hit/miss inside the fetch handlerfunction recordCacheResult(isHit) { if (isHit) hitCount++; else missCount++; // Report every 50 results to keep messaging overhead low 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), }); } }); }}
On the page side, receive the message and display or log the hit rate.
// App side (e.g. main.js)navigator.serviceWorker.addEventListener('message', event => { if (event.data?.type !== 'cache-stats') return; const { hit, miss, hitRate } = event.data; const saved = hit; // hits = API calls you avoided console.log( `cache hit ${(hitRate * 100).toFixed(1)}% / calls avoided: ${saved}` ); // Optional: send to your own metrics endpoint with a lightweight beacon navigator.sendBeacon?.('/metrics/cache', JSON.stringify({ hit, miss }));});
The hit count is, directly, the number of Gemini API calls you avoided. In a small AI app I run myself, screens with frequent repeat questions settled at roughly a 35–40% hit rate. That means the month's API calls dropped by about 40% in absolute terms — which, on a Flash-heavy setup, matches what I see on the bill almost exactly.
Hit rate varies a lot by strategy. Here are three patterns I've tried in production.
Cache strategy
Typical hit rate
Best for
Exact-match key only
~10–15%
Form-style inputs with little variation
Normalized key (lowercase, whitespace collapse)
~25–30%
Chat/search where phrasing varies
Normalized + semantic nearest (embedding match)
~35–45%
Apps with many FAQ-style answers
Going as far as semantic matching adds embedding compute, but that cost is far smaller than a single generation, and it pays off more as call volume grows. I'd recommend starting with normalized keys and adding nearest-neighbor matching only once the hit rate plateaus.
Once you hold these numbers, decisions like extending cache TTL or routing between models become things you can argue with data rather than intuition. Trimming cost while watching what's actually working tends to produce a more sustainable optimization than speeding things up on a hunch.
Common Pitfalls and How to Avoid Them
Three issues I hit hard enough to be worth saving you the time.
Pitfall 1: API keys in Service Workers
Service Worker source code is fully visible in DevTools to anyone who opens it. Even if you use environment variables at build time, the key ends up in plain text in the compiled sw.js. Always route Gemini API calls through a backend proxy. The Service Worker should call /api/gemini on your own domain, not generativelanguage.googleapis.com directly.
Pitfall 2: Caching opaque responses
If you fetch a cross-origin resource without specifying mode: 'cors', you get an "opaque response" — a response where the status code is always 0. The Cache API accepts opaque responses, but since you can't check response.ok, you might cache error responses. Always use mode: 'cors' for Gemini API calls and verify response.ok before writing to the cache.
Pitfall 3: Service Worker scope vs. cache key collisions
If you run development and production on the same origin (common with local proxy setups), different API keys can cause unexpected cache hits. The safest solution is versioned cache names (gemini-cache-v2) combined with environment-specific prefixes in your cache key generation.
For more on Gemini API fundamentals, the quickstart guide here covers authentication and basic request structure. For a Next.js-specific integration, this guide goes into detail on App Router patterns.
manifest.json and Lighthouse PWA Scoring
For browsers to recognize your app as a PWA, the manifest needs to be complete and correctly linked:
The maskable purpose for icons is easy to overlook but makes a visible difference on Android — without it, your icon may appear with a white square background on home screens that use adaptive icon shapes.
After deployment, run Lighthouse in Chrome DevTools and check the "Progressive Web App" section. It validates HTTPS, Service Worker registration, offline URL handling, and manifest completeness. From my experience, the most common reason for a failed audit is a missing offline.html that the Service Worker is supposed to serve when the network is unavailable.
Where to Go From Here
The foundation you've built — offline-capable, installable, push-enabled — puts your Gemini API app in a different category from most AI web tools. The next natural expansion is a Cloudflare Workers backend to handle the proxy layer, which also gives you edge caching for globally consistent performance. The Cloudflare Workers + Gemini guide here covers that architecture in depth.
Start small: add a sw.js with updateViaCache: 'none' to a project you're already working on, register it, and verify it shows up in DevTools. From there, layer in the caching strategy, IndexedDB persistence, and Background Sync incrementally. Each step independently improves the user experience — you don't need to ship everything at once.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.