Desktop PWA Guide for Dengue Dashboard (Vue 3 + Vite)
Disclaimer: All code snippets in this document are illustrative examples and will not work as-is. You must adapt, integrate, and test them in this project before they compile or run.
This guide explains what a PWA is and how to implement it in this project so the app is installable on desktop and works fully offline. It uses a service worker for the app shell and IndexedDB for API data caching.
What is a PWA (in our use case)?
- Installable desktop web app (app icon, manifest, standalone window, optional window controls overlay).
- Offline capable:
- Static assets (JS, CSS, HTML, fonts, images) are precached by Workbox at build time.
- JSON API data is cached in IndexedDB by the service worker and served when offline.
- Auto-updates: when a new version is deployed, the service worker updates caches and prompts the app to refresh.
Architecture overview
- App shell: Vue SPA built by Vite, precached for instant offline start.
- Data caching:
- Same-origin GET JSON responses cached in IndexedDB with a network-first strategy and offline fallback.
- Images and fonts cached at runtime for performance.
- Routing: SPA navigation falls back to
index.htmloffline. - Updates: New service worker claims clients after install and cleans outdated caches.
1) Dependencies
Install the PWA plugin and helpers.
pnpm add -D vite-plugin-pwa workbox-window
pnpm add idb2) Vite configuration (vite-plugin-pwa)
Add the PWA plugin, a desktop-oriented manifest, precache + runtime caches, and enable dev mode for local testing.
// vite.config.ts (excerpt)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import { fileURLToPath, URL } from 'node:url'
import { dirname, resolve } from 'node:path'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
VueI18nPlugin({
include: resolve(dirname(fileURLToPath(import.meta.url)), 'src/locales/**'),
}),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'auto',
devOptions: { enabled: true },
srcDir: 'src',
filename: 'sw.ts',
manifest: {
name: 'Dengue Dashboard',
short_name: 'Dengue',
description: 'Epidemiology dashboard with offline support',
theme_color: '#0b3d91',
background_color: '#ffffff',
display: 'standalone',
display_override: ['window-controls-overlay', 'standalone'],
start_url: '/',
scope: '/',
categories: ['productivity', 'utilities'],
icons: [
{ src: '/icons/pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/pwa-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icons/maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
navigateFallback: '/index.html',
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: ({ request }) => request.destination === 'image',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'images',
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }
}
},
{
urlPattern: ({ request }) => request.destination === 'font',
handler: 'CacheFirst',
options: {
cacheName: 'fonts',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 365 }
}
}
]
}
})
],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
}
})3) Minimal HTML meta (desktop)
The plugin injects the manifest; add a theme color.
<!-- index.html (excerpt) -->
<meta name="theme-color" content="#0b3d91" />Place icons under public/icons/:
pwa-192.pngpwa-512.pngmaskable-512.png
4) Register the service worker in the app
Use the plugin’s virtual module in src/main.ts to enable auto updates.
// src/main.ts (excerpt)
import { registerSW } from 'virtual:pwa-register'
const updateSW = registerSW({
immediate: true,
onNeedRefresh: () => updateSW(true),
onOfflineReady: () => {}
})5) IndexedDB helper used by the service worker
A small helper to store JSON by URL with timestamps and optional ETag.
// src/sw-idb.ts (example)
import { openDB, type IDBPDatabase } from 'idb'
export type ApiCacheRecord = {
url: string
etag?: string
timestamp: number
data: unknown
}
const DB_NAME = 'ddb-cache'
const DB_VERSION = 1
const STORE = 'api-cache'
let dbPromise: Promise<IDBPDatabase> | null = null
export function getDb() {
if (!dbPromise) {
dbPromise = openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: 'url' })
store.createIndex('timestamp', 'timestamp')
}
}
})
}
return dbPromise
}
export async function putApi(url: string, payload: unknown, etag?: string) {
const db = await getDb()
const rec: ApiCacheRecord = { url, etag, timestamp: Date.now(), data: payload }
await db.put(STORE, rec)
}
export async function getApi(url: string): Promise<ApiCacheRecord | undefined> {
const db = await getDb()
return db.get(STORE, url)
}6) Service worker (TypeScript) with IndexedDB JSON caching
- Precaches build assets.
- SPA navigation fallback to
/index.html. - Runtime caches for images and fonts.
- Intercepts same-origin GET JSON requests, excluding auth endpoints like
/renew_tokenand/login. Uses network-first with offline fallback to IndexedDB.
// src/sw.ts (example)
/* eslint-disable no-restricted-globals */
/// <reference lib="WebWorker" />
export type {}
import { precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { putApi, getApi } from './sw-idb'
// Injected by VitePWA
// @ts-ignore
precacheAndRoute(self.__WB_MANIFEST || [])
// Desktop SPA navigation fallback
registerRoute(new NavigationRoute(async () => fetch('/index.html', { cache: 'reload' })))
// Images
registerRoute(
({ request, url }) => request.destination === 'image' && url.origin === self.location.origin,
new StaleWhileRevalidate({ cacheName: 'images' })
)
// Fonts
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: 'fonts',
plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 365 })]
})
)
// JSON API caching with IndexedDB (network-first)
const isSameOriginJsonGet = (req: Request) => {
if (req.method !== 'GET') return false
const accept = req.headers.get('accept') || ''
if (!accept.includes('application/json')) return false
const u = new URL(req.url)
if (u.origin !== self.location.origin) return false
// Exclude sensitive/auth endpoints
if (u.pathname.startsWith('/renew_token') || u.pathname.startsWith('/login')) return false
return true
}
self.addEventListener('fetch', (event: FetchEvent) => {
const req = event.request
if (!isSameOriginJsonGet(req)) return
event.respondWith(handleApi(req))
})
async function handleApi(req: Request): Promise<Response> {
try {
const cached = await getApi(req.url)
const headers: HeadersInit = {}
if (cached?.etag) headers['If-None-Match'] = cached.etag
const netRes = await fetch(new Request(req, { headers }), { cache: 'no-store' })
if (netRes.status === 304 && cached) {
return new Response(JSON.stringify(cached.data), { headers: { 'Content-Type': 'application/json' } })
}
const resClone = netRes.clone()
const ct = resClone.headers.get('content-type') || ''
if (resClone.ok && ct.includes('application/json')) {
const data = await resClone.json()
const etag = resClone.headers.get('etag') ?? undefined
await putApi(req.url, data, etag)
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
status: netRes.status,
statusText: netRes.statusText
})
}
return netRes
} catch {
const cached = await getApi(req.url)
if (cached) {
return new Response(JSON.stringify(cached.data), { headers: { 'Content-Type': 'application/json' } })
}
return new Response(JSON.stringify({ error: 'offline' }), {
headers: { 'Content-Type': 'application/json' },
status: 503
})
}
}
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', () => {
// @ts-ignore
self.clients.claim()
})Leaflet tiles are typically served from third-party tile servers (cross-origin) and may have CORS and caching restrictions. Options:
- Self-host tiles or use a provider that allows CORS and caching.
- Or configure a conservative runtime cache for
request.destination === 'image'with respect to provider terms.
7) App works offline
- App shell: precached by Workbox; SPA navigation fallback ensures routes render.
- Data: first online visit seeds IndexedDB; subsequent offline visits serve cached JSON.
- Consider seeding essential datasets at install/first run if needed.
8) Commands (WSL / pnpm)
# install
pnpm install
# dev (with SW in dev mode)
pnpm dev
# build and preview (uses the generated service worker)
pnpm build
pnpm preview9) Testing
- Chrome DevTools > Application > Service Workers: check "Offline" and reload to verify offline capability.
- Lighthouse: run PWA audits to confirm installability and offline readiness.
- Cypress idea: load a page online to seed cache, programmatically toggle offline via CDP, assert content renders from cache.
10) Deployment
- Must be served over HTTPS (except
http://localhost). - Configure the server to serve
index.htmlfor unknown routes (SPA fallback). - Service worker updates: the plugin produces versioned assets; with
registerType: 'autoUpdate', users typically need one reload for new versions.
11) Security and limits
- Do not cache sensitive endpoints (login, refresh token, user profile) in IndexedDB.
- Only cache idempotent GET JSON responses; do not cache POST/PUT/PATCH/DELETE.
- When offline, authenticated flows are limited; design the UI to handle offline read-only where appropriate.
Summary
- We use
vite-plugin-pwa+ Workbox to precache the app shell and provide desktop installability. - We cache JSON API data in IndexedDB with a network-first strategy and offline fallback.
- The app runs offline and auto-updates when a new version is deployed.