Progressive Web Apps 2026: Offline-First with Service Worker, Workbox and Vue.js

Posted on: 4/26/2026 12:29:09 PM

Users expect web apps to work as smoothly as native apps — fast loading, offline support, push notifications, and installable directly from the browser without an App Store. Progressive Web Apps (PWA) make that expectation a reality, and in 2026, all major browsers fully support the core APIs: Service Worker, Web App Manifest, and Web Push — including Safari on iOS.

2-3xFaster repeat loads from SW cache
68%Increase in mobile engagement with PWA
1 codebaseRuns on all platforms: Desktop, Mobile, Tablet
$0App Store fees — install directly from browser

1. What is a PWA and Why Does It Matter?

A PWA is a web application enhanced with three core technologies: Service Worker (a network proxy running in the background, enabling offline and caching), Web App Manifest (a JSON file describing metadata so browsers allow "installation"), and HTTPS (mandatory because Service Workers intercept network requests). Together, they create an app-like experience without native code.

flowchart TB
    A["User visits Web App"] --> B{"Service Worker
installed?"} B -->|"No"| C["Load page normally
+ Register SW"] B -->|"Yes"| D["SW intercepts request"] D --> E{"In Cache?"} E -->|"Yes"| F["Return from Cache
(instant load)"] E -->|"No"| G["Fetch from Network"] G --> H["Cache new response"] C --> I{"Meets install
criteria?"} I -->|"Yes"| J["Show Install Prompt"] J --> K["Install PWA to Home Screen"] style D fill:#e94560,stroke:#fff,color:#fff style F fill:#4CAF50,stroke:#fff,color:#fff style K fill:#2c3e50,stroke:#fff,color:#fff
PWA lifecycle: from first visit to Home Screen installation

2. Setting Up PWA with Vue 3 and Vite

2.1. Install vite-plugin-pwa

npm install -D vite-plugin-pwa

2.2. Configure in vite.config.ts

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { VitePWA } from 'vite-plugin-pwa' export default defineConfig({ plugins: [ vue(), VitePWA({ registerType: 'autoUpdate', includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'], manifest: { name: 'My Vue PWA', short_name: 'VuePWA', description: 'Vue 3 Progressive Web App', theme_color: '#e94560', background_color: '#ffffff', display: 'standalone', start_url: '/', icons: [ { src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512x512.png', sizes: '512x512', type: 'image/png' }, { src: '/icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } ] }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], runtimeCaching: [ { urlPattern: /^https:\/\/api\.example\.com\/.*/i, handler: 'NetworkFirst', options: { cacheName: 'api-cache', expiration: { maxEntries: 50, maxAgeSeconds: 300 } } }, { urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/, handler: 'CacheFirst', options: { cacheName: 'image-cache', expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 } } } ] } }) ] })

registerType: 'autoUpdate' vs 'prompt'

autoUpdate automatically updates the Service Worker when a new version is available — users don't need to do anything. prompt shows a notification letting users choose whether to update — ideal when you need tighter UX control (e.g., financial apps where a sudden reload could cause form data loss).

3. Service Worker — The Brain of PWA

A Service Worker is JavaScript running on a background thread, independent of the web page. It acts as a network proxy — every HTTP request from your application passes through the Service Worker before reaching the internet, letting you decide: serve from cache, fetch from network, or combine both.

3.1. Service Worker Lifecycle

flowchart LR
    A["Register"] --> B["Install"]
    B --> C["Activate"]
    C --> D["Running
(intercept fetch)"] D --> E{"New SW
available?"} E -->|"Yes"| F["Install new
(waiting)"] F --> G["Activate new
(replace old)"] G --> D E -->|"No"| D style B fill:#e94560,stroke:#fff,color:#fff style C fill:#4CAF50,stroke:#fff,color:#fff style D fill:#2c3e50,stroke:#fff,color:#fff
Service Worker lifecycle: install → activate → running → update

3.2. Custom Service Worker in Vue

// src/sw.ts — custom service worker import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies' import { ExpirationPlugin } from 'workbox-expiration' declare let self: ServiceWorkerGlobalScope // Precache all assets from build precacheAndRoute(self.__WB_MANIFEST) cleanupOutdatedCaches() // API calls: Network First (prioritize fresh data) registerRoute( ({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: 'api-responses', plugins: [ new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 }) ] }) ) // Font files: Cache First (rarely change) registerRoute( ({ request }) => request.destination === 'font', new CacheFirst({ cacheName: 'font-cache', plugins: [ new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * 24 * 60 * 60 }) ] }) ) // HTML pages: Stale While Revalidate (show cached, update in background) registerRoute( ({ request }) => request.mode === 'navigate', new StaleWhileRevalidate({ cacheName: 'pages-cache' }) )

4. Caching Strategies

Choosing the right caching strategy is the key factor in PWA experience. Each strategy fits a different type of resource:

StrategyHow It WorksUse ForTrade-off
Cache FirstCheck cache first, fetch network on missImages, fonts, hashed CSS/JSFastest, but data may be stale
Network FirstFetch network first, fallback to cache offlineAPI responses, dynamic dataAlways fresh, but slower
Stale While RevalidateReturn cache immediately, fetch network to updateHTML pages, product listingsBalances speed and freshness
Network OnlyAlways fetch network, no cacheAnalytics, payment APINo offline support
Cache OnlyAlways return from cache, never fetchPrecached assetsFastest, never updates
flowchart LR
    subgraph CF["Cache First"]
        A1["Request"] --> A2{"Cache?"}
        A2 -->|"Hit"| A3["Return Cache"]
        A2 -->|"Miss"| A4["Fetch Network"]
    end

    subgraph NF["Network First"]
        B1["Request"] --> B2["Fetch Network"]
        B2 -->|"OK"| B3["Return + Cache"]
        B2 -->|"Fail"| B4["Return Cache"]
    end

    subgraph SWR["Stale While Revalidate"]
        C1["Request"] --> C2["Return Cache"]
        C1 --> C3["Fetch Network"]
        C3 --> C4["Update Cache"]
    end

    style A3 fill:#4CAF50,stroke:#fff,color:#fff
    style B3 fill:#4CAF50,stroke:#fff,color:#fff
    style C2 fill:#4CAF50,stroke:#fff,color:#fff
The three most common PWA caching strategies

5. Web App Manifest — Turning Web into "App"

The Web App Manifest is a JSON file telling browsers how your app should look when "installed" — name, icon, theme color, display mode, and orientation. This is mandatory for browsers to show the install prompt:

{ "name": "Vue E-Commerce PWA", "short_name": "VueShop", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#e94560", "description": "Shop anytime, even when offline", "categories": ["shopping", "lifestyle"], "orientation": "any", "icons": [ { "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" }, { "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" }, { "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icons/maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "screenshots": [ { "src": "/screenshots/home.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" }, { "src": "/screenshots/home-mobile.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow" } ] }

Install Prompt Requirements

Chrome requires: HTTPS, registered Service Worker, Web App Manifest with name, icons (at least 192px and 512px), start_url, display (standalone/fullscreen). Safari on iOS supports Add to Home Screen but doesn't show automatic prompts — users must use Share → Add to Home Screen.

6. Custom Install Prompt with Vue 3

<script setup lang="ts"> import { ref, onMounted } from 'vue' const deferredPrompt = ref<BeforeInstallPromptEvent | null>(null) const showInstallBanner = ref(false) onMounted(() => { window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault() deferredPrompt.value = e as BeforeInstallPromptEvent showInstallBanner.value = true }) window.addEventListener('appinstalled', () => { showInstallBanner.value = false deferredPrompt.value = null }) }) async function installApp() { if (!deferredPrompt.value) return deferredPrompt.value.prompt() const { outcome } = await deferredPrompt.value.userChoice if (outcome === 'accepted') { showInstallBanner.value = false } deferredPrompt.value = null } </script> <template> <Transition name="slide-up"> <div v-if="showInstallBanner" class="install-banner"> <p>Install the app for faster, offline experience</p> <button @click="installApp">Install</button> <button @click="showInstallBanner = false">Later</button> </div> </Transition> </template>

7. Push Notifications

Push Notifications are the only channel that can reach users even when the tab is closed. They work through the Push API combined with Service Worker:

sequenceDiagram
    participant U as User (Browser)
    participant SW as Service Worker
    participant S as Your Server
    participant P as Push Service
(FCM / APNs) U->>SW: Notification.requestPermission() SW->>P: PushManager.subscribe() P-->>SW: PushSubscription (endpoint + keys) SW->>S: POST /api/push/subscribe S->>S: Store subscription Note over S: When a new event occurs... S->>P: POST endpoint (payload + VAPID) P->>SW: Push event SW->>U: self.registration.showNotification()
Push Notification flow: subscribe → server stores → push via FCM/APNs → SW displays

7.1. Registering Push Subscription

// composables/usePushNotification.ts export function usePushNotification() { async function subscribe() { const permission = await Notification.requestPermission() if (permission !== 'granted') return null 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 subscription } return { subscribe } }

7.2. Receiving Push in Service Worker

// In service worker self.addEventListener('push', (event) => { const data = event.data?.json() ?? { title: 'New notification', body: 'You have a new update' } event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: '/icons/icon-192x192.png', badge: '/icons/badge-72x72.png', data: { url: data.url ?? '/' }, actions: [ { action: 'open', title: 'View now' }, { action: 'dismiss', title: 'Dismiss' } ] }) ) }) self.addEventListener('notificationclick', (event) => { event.notification.close() if (event.action === 'open' || !event.action) { event.waitUntil( clients.openWindow(event.notification.data.url) ) } })

7.3. Sending Push from .NET Backend

// ASP.NET Core — send push notification using WebPush; public class PushNotificationService { private readonly VapidDetails _vapid; public PushNotificationService(IConfiguration config) { _vapid = new VapidDetails( "mailto:admin@example.com", config["Vapid:PublicKey"]!, config["Vapid:PrivateKey"]! ); } public async Task SendAsync(PushSubscription subscription, string title, string body, string url) { var client = new WebPushClient(); var payload = JsonSerializer.Serialize(new { title, body, url }); await client.SendNotificationAsync(subscription, payload, _vapid); } }

8. Background Sync — Handling Offline Form Submissions

When a user submits a form while offline, Background Sync queues the request and automatically retries when connectivity returns — no user action needed:

// In Vue component async function submitOrder(order: Order) { try { await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(order) }) } catch { // Offline: register sync task const registration = await navigator.serviceWorker.ready await registration.sync.register('sync-orders') // Save data to IndexedDB for SW to read later await saveToIndexedDB('pending-orders', order) } } // In Service Worker self.addEventListener('sync', (event) => { if (event.tag === 'sync-orders') { event.waitUntil(syncPendingOrders()) } }) async function syncPendingOrders() { const orders = await getFromIndexedDB('pending-orders') for (const order of orders) { await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(order) }) await removeFromIndexedDB('pending-orders', order.id) } }

9. PWA with Nuxt 4

Nuxt 4 supports PWA through the @vite-pwa/nuxt module, naturally combining SSR/SSG with Service Worker:

// nuxt.config.ts export default defineNuxtConfig({ modules: ['@vite-pwa/nuxt'], pwa: { registerType: 'autoUpdate', manifest: { name: 'Nuxt PWA App', short_name: 'NuxtPWA', theme_color: '#e94560', display: 'standalone', icons: [ { src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512x512.png', sizes: '512x512', type: 'image/png' } ] }, workbox: { navigateFallback: '/', globPatterns: ['**/*.{js,css,html,png,svg,ico}'] }, client: { installPrompt: true } } })

10. PWA vs Native App vs Hybrid

CriteriaPWANative AppHybrid (Capacitor)
Development1 codebase (Web)2+ codebases (iOS + Android)1 codebase + native bridge
DistributionURL + Install PromptApp Store / Play StoreApp Store + Web
OfflineService Worker cacheFull native storageHybrid
Push NotificationsWeb Push (all platforms 2026)APNs / FCM nativeNative push
Hardware AccessCamera, GPS, Bluetooth (limited)Full accessVia plugins
PerformanceGood (Wasm boost)BestNear-native
Dev CostLowHighMedium
UpdatesInstant (SW update)App Store reviewHybrid

When to Choose PWA?

PWA is the best fit when: your app primarily displays content (e-commerce, news, SaaS dashboards), you need wide reach without app stores, your team already has web skills (Vue/React), and you don't require deep hardware access (complex NFC, Bluetooth LE). For heavy processing (games, video editing) or specialized hardware — native or hybrid is the better choice.

11. Performance Checklist for PWA

  • Precache shell: App shell (HTML, CSS, main JS) must load from cache — ensuring First Paint under 1 second.
  • Lazy load routes: Use defineAsyncComponent and Vue Router lazy loading — only load code when user navigates.
  • Image optimization: Serve AVIF/WebP, responsive images with srcset, and lazy loading with loading="lazy".
  • Cache API responses: Use NetworkFirst for frequently changing data, CacheFirst for rarely changing data.
  • Minimize SW scope: Only cache what's necessary — caching too much wastes storage and slows SW install.
  • Navigation Preload: Enable navigationPreload so network requests start in parallel with SW startup, reducing latency for the first page.

12. Conclusion

PWA in 2026 is no longer experimental — it's the most efficient app distribution strategy for the majority of web use cases. With Vue 3 + Vite + vite-plugin-pwa, you can turn any Vue app into a PWA in minutes, benefiting from Service Worker caching, offline capability, push notifications, and install prompts — all with a single codebase.

Get Started Now

1. npm install -D vite-plugin-pwa → 2. Add VitePWA plugin to vite.config.ts → 3. Create 192px + 512px icons → 4. Build and test with Lighthouse. The entire process takes under 10 minutes for an existing Vue app.

References