Skip to content

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.html offline.
  • Updates: New service worker claims clients after install and cleans outdated caches.

1) Dependencies

Install the PWA plugin and helpers.

bash
pnpm add -D vite-plugin-pwa workbox-window
pnpm add idb

2) Vite configuration (vite-plugin-pwa)

Add the PWA plugin, a desktop-oriented manifest, precache + runtime caches, and enable dev mode for local testing.

ts
// 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.

html
<!-- index.html (excerpt) -->
<meta name="theme-color" content="#0b3d91" />

Place icons under public/icons/:

  • pwa-192.png
  • pwa-512.png
  • maskable-512.png

4) Register the service worker in the app

Use the plugin’s virtual module in src/main.ts to enable auto updates.

ts
// 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.

ts
// 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_token and /login. Uses network-first with offline fallback to IndexedDB.
ts
// 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)

bash
# install
pnpm install

# dev (with SW in dev mode)
pnpm dev

# build and preview (uses the generated service worker)
pnpm build
pnpm preview

9) 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.html for 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.