Progressive Web Apps 2026: Offline-First with Service Worker, Workbox and Vue.js
Posted on: 4/26/2026 12:29:09 PM
Table of contents
- 1. What is a PWA and Why Does It Matter?
- 2. Setting Up PWA with Vue 3 and Vite
- 3. Service Worker — The Brain of PWA
- 4. Caching Strategies
- 5. Web App Manifest — Turning Web into "App"
- 6. Custom Install Prompt with Vue 3
- 7. Push Notifications
- 8. Background Sync — Handling Offline Form Submissions
- 9. PWA with Nuxt 4
- 10. PWA vs Native App vs Hybrid
- 11. Performance Checklist for PWA
- 12. Conclusion
- References
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.
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
2. Setting Up PWA with Vue 3 and Vite
2.1. Install vite-plugin-pwa
npm install -D vite-plugin-pwa2.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
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:
| Strategy | How It Works | Use For | Trade-off |
|---|---|---|---|
| Cache First | Check cache first, fetch network on miss | Images, fonts, hashed CSS/JS | Fastest, but data may be stale |
| Network First | Fetch network first, fallback to cache offline | API responses, dynamic data | Always fresh, but slower |
| Stale While Revalidate | Return cache immediately, fetch network to update | HTML pages, product listings | Balances speed and freshness |
| Network Only | Always fetch network, no cache | Analytics, payment API | No offline support |
| Cache Only | Always return from cache, never fetch | Precached assets | Fastest, 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
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()
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
| Criteria | PWA | Native App | Hybrid (Capacitor) |
|---|---|---|---|
| Development | 1 codebase (Web) | 2+ codebases (iOS + Android) | 1 codebase + native bridge |
| Distribution | URL + Install Prompt | App Store / Play Store | App Store + Web |
| Offline | Service Worker cache | Full native storage | Hybrid |
| Push Notifications | Web Push (all platforms 2026) | APNs / FCM native | Native push |
| Hardware Access | Camera, GPS, Bluetooth (limited) | Full access | Via plugins |
| Performance | Good (Wasm boost) | Best | Near-native |
| Dev Cost | Low | High | Medium |
| Updates | Instant (SW update) | App Store review | Hybrid |
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
defineAsyncComponentand Vue Router lazy loading — only load code when user navigates. - Image optimization: Serve AVIF/WebP, responsive images with
srcset, and lazy loading withloading="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
navigationPreloadso 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
Cloudflare Workers + Durable Objects — Building Stateful Serverless Apps on the Edge
Vitest 4 — Testing Framework 28x Faster Than Jest for Vue and Vite
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.