PWA #
A Progressive Web Application is a collection of technologies letting web applications behave like native apps: installable to the home screen, working offline, receiving push notifications, and loading content faster from servers with smart caching. There’s no single technology called “PWA” — instead, there are three pillars working together: the Service Worker, the Web App Manifest, and HTTPS. Understanding how each pillar works, especially the Service Worker and its caching strategies, is the key to building PWAs that are genuinely useful rather than just adding a manifest file and calling it a PWA. This article covers each pillar in depth, the right caching strategies for different content types, and when a PWA is worth considering.
The Three PWA Pillars #
flowchart TD
subgraph Pillars["The Three PWA Pillars"]
SW["Service Worker\nJavaScript proxy running\non a background thread\n→ Offline support\n→ Cache control\n→ Push notifications\n→ Background sync"]
Manifest["Web App Manifest\nJSON file defining\nthe app's identity and appearance\n→ Name and icons\n→ Start URL\n→ Display mode\n→ Theme color"]
HTTPS["HTTPS\nSecure protocol mandatory\nfor all PWAs\n→ Service Workers only\n work over HTTPS\n→ Prevents man-in-\n the-middle attacks"]
end
subgraph Capabilities["Capabilities Produced"]
Offline["Offline Support"]
Install["Installable"]
Push["Push Notifications"]
Fast["Fast Loading"]
end
SW --> Offline
SW --> Push
SW --> Fast
Manifest --> Install
HTTPS --> SWThe Service Worker — PWA’s Heart #
The Service Worker is a JavaScript file running on a background thread separate from the page’s main thread. It acts as a proxy between the app and the network — every request the app makes can be intercepted, modified, or answered from the cache by the Service Worker.
Service Worker lifecycle:
1. Registration — the app registers the service worker
navigator.serviceWorker.register('/sw.js')
2. Installation — the browser downloads and installs sw.js
→ The 'install' event fires in the service worker
→ The chance to pre-cache important assets
3. Activation — the service worker becomes active, controlling the page
→ The 'activate' event fires
→ The chance to clean up old caches
4. Fetch Interception — the service worker intercepts all requests
→ The 'fetch' event fires for every request
→ Can return from the cache or go to the network
Important: Service Workers only work over HTTPS (except localhost)
Service Workers run in the background — no DOM access
sequenceDiagram
participant Page as Web Page
participant SW as Service Worker
participant Cache as Cache Storage
participant Net as Network
Page->>SW: fetch('/api/products')
Note over SW: SW intercepts the request
SW->>Cache: Check whether it's in the cache
alt In the cache (Cache Hit)
Cache-->>SW: Return the cached response
SW-->>Page: Response from cache (very fast!)
SW->>Net: Fetch in the background to update the cache
else Not in the cache (Cache Miss)
SW->>Net: Fetch from the network
Net-->>SW: Response from the server
SW->>Cache: Save the response to the cache
SW-->>Page: Return the response
endService Worker Caching Strategies #
This is the most critical part of building a PWA. Wrong caching strategies can leave users with outdated content or no content at all when offline.
// sw middleware — service worker with multiple caching strategies
// (on startup: pre-cache critical assets + clean up old caches)
const STATIC_CACHE = "static-v1"
const API_CACHE = "api-v1"
// === STARTUP: Pre-cache critical assets ===
func init() {
// cache.AddAll("/", "/index.html", "/main.bundle.js", "/app.css", ...)
// (assets REQUIRED for the app shell to work)
}
// === CLEANUP: Delete old caches ===
func cleanupOldCaches() {
// delete every cache that is not STATIC_CACHE or API_CACHE
}
// === REQUEST: Choose a strategy based on request type ===
func cacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Static assets → Cache First
if isStaticAsset(r.URL) {
cacheFirst(w, r, STATIC_CACHE)
return
}
// API data → Network First
if strings.HasPrefix(r.URL.Path, "/api/") {
networkFirst(w, r, API_CACHE)
return
}
// HTML pages → Stale While Revalidate
if strings.Contains(r.Header.Get("Accept"), "text/html") {
staleWhileRevalidate(w, r, STATIC_CACHE)
return
}
next.ServeHTTP(w, r)
})
}
Strategy 1: Cache First #
Prioritize the cache, use the network only as a fallback. Ideal for rarely changing assets.
// Cache First: prioritize the cache, use the network only as a fallback
func cacheFirst(cache *Cache, req *http.Request) *http.Response {
if cached := cache.Get(req); cached != nil {
return cached // return from the cache — very fast
}
// Not in the cache, fetch from the network
resp := httpClient.Do(req)
cache.Put(req, resp.Clone()) // save for next time
return resp
}
Strategy 2: Network First #
Prioritize the network, use the cache as a fallback when offline. Ideal for frequently changing content.
// Network First: prioritize the network, use the cache as a fallback when offline
func networkFirst(cache *Cache, req *http.Request) *http.Response {
resp, err := httpClient.Do(req)
if err == nil {
// Save the response to the cache for offline fallbacks
cache.Put(req, resp.Clone())
return resp
}
// Network failed (offline) — try the cache
if cached := cache.Get(req); cached != nil {
return cached
}
// Not in the cache either — return the offline page
return cache.Get(offlineURL)
}
Strategy 3: Stale While Revalidate #
Return the cache immediately (fast), update the cache in the background. Best for content that can be slightly outdated.
// Stale While Revalidate: return the cache immediately, update it in the background
func staleWhileRevalidate(cache *Cache, req *http.Request) *http.Response {
cached := cache.Get(req)
// Update the cache in the background (no waiting)
go func() {
if resp, err := httpClient.Do(req); err == nil {
cache.Put(req, resp.Clone())
}
}()
// Return the cache immediately if present, or wait for the network
if cached != nil {
return cached
}
resp, _ := httpClient.Do(req)
return resp
}
The Web App Manifest — PWA Identity #
The Web App Manifest is a JSON file telling the browser how the app should behave when installed on a device.
// /manifest.json
{
"name": "Online Store App",
"short_name": "Store",
"description": "Easy and fast online shopping",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#2C3E50",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"shortcuts": [
{
"name": "Cart",
"url": "/cart",
"icons": [{ "src": "/icons/cart.png", "sizes": "96x96" }]
}
]
}
Available display options:
"standalone": Looks like a native app — no browser UI (address bar, etc.)
→ The most "native" experience — recommended for most PWAs
"minimal-ui": Minimal address bar (only back/forward + URL)
→ A compromise between web and native
"browser": Normal browser appearance — the same as a regular tab
→ No different from a regular website, less recommended
"fullscreen": Full screen, no browser UI at all
→ For games or media apps needing full screen
Offline Experience — Meaningful UX #
Offline support isn’t just about “not crashing offline” — it’s about providing a meaningful experience even without a connection.
Offline experience levels from worst to best:
Level 0 — Crash / Error:
"No internet connection" from the browser
→ No handling at all
Level 1 — Offline Page:
A generic "You are offline" page
→ Better than browser errors, but not informative
→ The minimum that should be implemented
Level 2 — Cached Content:
Users can view content they've visited before
→ "You're viewing a saved version from 2 hours ago"
→ Very useful for read-heavy apps
Level 3 — Full Offline Functionality:
Users can make changes while offline
→ Changes sync when the connection returns (Background Sync)
→ The experience closest to native apps
// An informative offline page — /offline.html or an offline component
type OfflinePage struct {
lastOnline time.Time
isBackOnline bool
}
// Listen for the 'online' event while the page is open
func (p *OfflinePage) start() {
go func() {
// connection restored → mark it, then reload after 1.5s
p.isBackOnline = true
time.AfterFunc(1500*time.Millisecond, reloadPage)
}()
}
func (p *OfflinePage) render() string {
if p.isBackOnline {
return "<div>Connection restored! Reloading the page...</div>"
}
// <div className="offline-page">
// <WifiOffIcon />
// <h1>No Internet Connection</h1>
// <p>You are offline. Some features may be unavailable.</p>
// {lastOnline && <p>Content last updated: ...</p>}
// <button onClick={reloadPage}>Try Again</button>
// </div>
return offlinePageHTML(p.lastOnline)
}
Push Notifications #
Push Notifications let servers send messages to users even when the browser is closed. This is one of the features most distinguishing PWAs from regular websites.
sequenceDiagram
participant U as User
participant App as PWA
participant SW as Service Worker
participant PS as Push Server (VAPID)
participant Backend as Backend
U->>App: Clicks "Allow Notifications"
App->>PS: Subscribe (send VAPID public key)
PS-->>App: PushSubscription object
App->>Backend: Save the subscription (endpoint + keys)
Note over Backend: When an event needs a notification
Backend->>PS: Send a message with the private key
PS->>SW: Deliver the push message to the browser
SW->>U: Show the notification (even if the browser is closed!)
U->>SW: Clicks the notification
SW->>App: Open the relevant page// Requesting notification permission
func requestNotificationPermission() (*PushSubscription, error) {
permission, err := notification.RequestPermission()
if err != nil {
return nil, err
}
if permission != "granted" {
log.Println("Notification denied by user")
return nil, nil
}
// Subscribe to the push service
registration, err := serviceWorker.Ready()
if err != nil {
return nil, err
}
subscription, err := registration.PushManager().Subscribe(PushSubscribeOptions{
UserVisibleOnly: true, // required true — no silent pushes allowed
ApplicationServerKey: urlBase64ToUint8Array(VAPIDPublicKey),
})
if err != nil {
return nil, err
}
// Send the subscription to the backend for storage
http.Post("/api/push/subscribe", "application/json", bytes.NewBuffer(subscription.JSON()))
return subscription, nil
}
// In the Service Worker — handle push events
func handlePush(data PushData) {
// show the notification with title, body, icon, badge, actions
showNotification(data.Title, NotificationOptions{
Body: data.Body,
Icon: "/icons/icon-192.png",
Badge: "/icons/badge-72.png",
Data: map[string]string{"url": data.ActionURL},
Actions: []NotificationAction{
{Action: "open", Title: "Open", Icon: "/icons/open.png"},
{Action: "dismiss", Title: "Dismiss"},
},
})
}
// Handle notification clicks
func handleNotificationClick(notification Notification, action string) {
notification.Close()
if action == "open" || action == "" {
openWindow(notification.Data["url"])
}
}
Don’t spam users with push notifications. Notification permission is one of the most frequently blocked permissions because of sites abusing this feature. Best practice: ask permission only after users perform an action showing intent (e.g. clicking a user-initiated “enable notifications” button), not immediately after the page opens. Spammed users will revoke permission and it can’t be requested again without explicit user action.
Background Sync — Offline Actions #
Background Sync lets actions performed offline sync when the connection returns, without users needing to reopen the app.
// In the app — register a sync when submitting a form while offline
func submitOrder(orderData Order) error {
if !navigator.OnLine() {
// Save to IndexedDB for later syncing
saveToIndexedDB("pending-orders", orderData)
// Register a background sync
registration, err := serviceWorker.Ready()
if err != nil {
return err
}
if err := registration.Sync().Register("sync-orders"); err != nil {
return err
}
showToast("Order saved. Will be sent when online.")
return nil
}
// Online — submit directly
_, err := http.Post("/api/orders", "application/json", bytes.NewBuffer(orderData.JSON()))
return err
}
// In the Service Worker — handle sync events
func handleSync(tag string) {
if tag == "sync-orders" {
syncPendingOrders()
}
}
func syncPendingOrders() error {
pendingOrders, err := getFromIndexedDB("pending-orders")
if err != nil {
return err
}
for _, order := range pendingOrders {
if _, err := http.Post("/api/orders", "application/json", bytes.NewBuffer(order.JSON())); err != nil {
// If it still fails, the sync will be retried later
log.Printf("Sync failed for order: %v", order.ID)
return err // re-throw so the browser knows the sync hasn't succeeded
}
deleteFromIndexedDB("pending-orders", order.ID)
}
return nil
}
The Difference Between PWAs and SPAs #
This is a very common confusion — many people think they’re the same.
SPA (Single Page Application):
→ A rendering architecture — one HTML document, client-side routing
→ No offline support by default
→ Not installable
→ No push notifications
→ Every SPA is a regular web app from the OS's perspective
PWA (Progressive Web Application):
→ A collection of technologies making the web more "native"
→ Works offline (via Service Worker + Cache)
→ Installable to the home screen / desktop
→ Can send push notifications
→ Looks like an app in the user's OS
Their relationship:
→ An SPA can become a PWA by adding a Service Worker + Manifest + HTTPS
→ PWAs can use SPA, MPA, or SSR architectures
→ An SPA isn't automatically a PWA
→ A PWA isn't a replacement for an SPA — they're different layers
Analogy:
SPA = the building type (single-story house)
PWA = the building features (AC, alarm, solar panels)
→ A single-story house may or may not have those features
→ Those features can be installed on one- or two-story houses
Installability — Add to Home Screen #
For a PWA to be installable, all of the following criteria must be met:
Chrome installability criteria:
✓ Served over HTTPS (or localhost for development)
✓ Web App Manifest present with the required fields:
- name or short_name
- icons (at minimum 192x192 and 512x512)
- start_url
- display: 'standalone', 'fullscreen', or 'minimal-ui'
✓ Service Worker registered with a fetch event handler
✓ Not previously installed on this device
When the criteria are met:
→ Chrome shows an "Install App" prompt or banner
→ Users can choose to install
Best practices for install prompts:
// Capture and store the event, show it when the user shows intent
let deferredPrompt = null
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault() // prevent the automatic prompt
deferredPrompt = e // save it for later
})
// Show it when the user clicks the "Install App" button
async function promptInstall() {
if (!deferredPrompt) return
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
// User installed — can track analytics here
}
deferredPrompt = null
}
PWA Anti-Patterns to Avoid #
Caching Every Request Without a Strategy #
// ✗ Anti-pattern: caching every request
func cacheEverything(w http.ResponseWriter, r *http.Request) {
if cached := cache.Get(r); cached != nil {
writeResponse(w, cached)
return
}
resp := fetch(r)
cache.Put(r, resp)
writeResponse(w, resp)
}
// Problems:
// → Caches expired authentication tokens
// → Caches error responses (404, 500) that keep being returned
// → Caches sensitive data that shouldn't be stored
// → The cache grows without bounds
// ✓ Solution: Use the right strategy per request type, with clear filters
func cacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Don't cache POST, PUT, DELETE
if r.Method != http.MethodGet {
next.ServeHTTP(w, r)
return
}
// Don't cache authentication requests
if strings.Contains(r.URL.Path, "/api/auth") {
next.ServeHTTP(w, r)
return
}
// Choose a strategy by type
if isStaticAsset(r.URL) {
cacheFirst(w, r)
} else if strings.Contains(r.URL.Path, "/api/") {
networkFirst(w, r)
} else {
next.ServeHTTP(w, r)
}
})
}
Unversioned Caches #
// ✗ Anti-pattern: static cache names without versions
const Cache = "my-cache"
// After a new deploy, old assets are still cached
// Users don't get updates until the cache expires on its own
// ✓ Solution: Version caches and clean up on startup
const CacheVersion = "v2" // increment on every deploy
const StaticCache = "static-" + CacheVersion
func cleanupOldCaches() {
// delete every cache that does not end with CacheVersion
}
Requesting Push Permission Immediately #
// ✗ Anti-pattern: requesting permission right when the page opens
func onDOMContentLoaded() {
notification.RequestPermission() // 70-80% of users immediately block this!
}
// ✓ Solution: Only ask after the user shows interest
func renderProfilePage() {
// <div>
// <h2>Notification Preferences</h2>
// <p>Get updates about orders and the latest promotions</p>
// <button onClick={requestNotificationPermission}>Enable Notifications</button>
// </div>
}
When to Use PWAs #
PWA is very appropriate for:
✓ Apps used periodically and regularly
(news apps, todos, productivity tools)
✓ Apps needing offline access
(apps in areas with unstable connections)
✓ Reaching mobile users without App Store publishing costs
(especially in emerging markets with limited storage)
✓ Apps needing push notifications
(reminders, order updates, breaking news)
✓ E-commerce apps needing high performance and engagement
PWA is less appropriate or offers no benefit if:
✗ Content visited only once (marketing landing pages)
✗ Hardware access needed that isn't available on the web
(Bluetooth peripherals, USB, complex NFC)
✗ Teams without Service Worker experience
→ A wrongly implemented Service Worker is worse than none
✗ Apps whose connections are always stable and fast
→ Service Worker overhead isn't worth it
PWA Checklist #
SERVICE WORKER:
□ Service Worker registered on the main page
□ Install events cache critical assets (app shell)
□ Activate events clean up old caches
□ Fetch events use the right strategy per request type
□ POST/PUT/DELETE requests not cached
□ Caches versioned (cleanup on new deploys)
□ Fallback to an offline page when no cache exists
WEB APP MANIFEST:
□ manifest.json exists and is valid
□ Icons available at minimum 192x192 and 512x512
□ display: 'standalone'
□ theme_color and background_color configured
□ start_url configured correctly
□ <link rel="manifest"> present on every page
HTTPS:
□ The entire app served over HTTPS
□ HTTP redirects to HTTPS
□ HSTS headers configured
INSTALLABILITY:
□ All installability criteria met (verifiable with Lighthouse)
□ Install prompts shown at the right time (user-initiated)
□ Post-install UX handled
OFFLINE:
□ App shell available offline
□ Offline page exists and is informative
□ Caches updated while online (stale-while-revalidate or background updates)
□ Offline changes synced when back online (Background Sync)
PUSH NOTIFICATIONS:
□ Permission requested at the right time (not on page load)
□ Notification content relevant and meaningful
□ Notification clicks open the relevant page
□ Unsubscribe available and easy to find
TESTING:
□ Tested in Chrome DevTools (Application tab)
□ Lighthouse PWA audit scores checked
□ Tested under offline conditions (Network throttling)
□ Tested on different mobile devices
Summary #
- PWA isn’t one technology — it’s three pillars — the Service Worker (offline, caching, push), the Web App Manifest (installability), and HTTPS (security, a Service Worker prerequisite).
- Choose the right caching strategy per content type — Cache First for static assets with content hashes, Network First for API data, Stale While Revalidate for content that can be slightly outdated.
- Don’t cache every request without filters — POST/PUT/DELETE must not be cached. Authentication requests must not be cached. Error responses must not be cached. A wrong cache is worse than no cache.
- Cache versioning is mandatory — without versioning, users keep getting old assets after new deploys. Increment the version in cache names and clean up in the activate event.
- Offline experiences must be meaningful — at minimum an informative offline page. Even better: show cached content with an indication of when it was last updated.
- Don’t spam push notifications — request permission only when users show interest, not on the first page open. Spammed users will block all notifications and can’t be asked again.
- Background Sync for offline actions — save actions to IndexedDB while offline, sync when the connection returns via the Background Sync API, even without users opening the app.
- Install prompts must be user-initiated — capture the beforeinstallprompt event, store it, and show it only when users show intent to install (not automatically).
- SPAs and PWAs are different concepts — SPA is a rendering architecture, PWA is a set of capabilities. An SPA can become a PWA, but an SPA isn’t automatically a PWA.
- Use Lighthouse for validation — Chrome DevTools Lighthouse provides a comprehensive PWA audit: which criteria are met and what needs fixing.
#
- PWA isn’t one technology — it’s three pillars — the Service Worker (offline, caching, push), the Web App Manifest (installability), and HTTPS (security, a Service Worker prerequisite).
- Choose the right caching strategy per content type — Cache First for static assets with content hashes, Network First for API data, Stale While Revalidate for content that can be slightly outdated.
- Don’t cache every request without filters — POST/PUT/DELETE must not be cached. Authentication requests must not be cached. Error responses must not be cached. A wrong cache is worse than no cache.
- Cache versioning is mandatory — without versioning, users keep getting old assets after new deploys. Increment the version in cache names and clean up in the activate event.
- Offline experiences must be meaningful — at minimum an informative offline page. Even better: show cached content with an indication of when it was last updated.
- Don’t spam push notifications — request permission only when users show interest, not on the first page open. Spammed users will block all notifications and can’t be asked again.
- Background Sync for offline actions — save actions to IndexedDB while offline, sync when the connection returns via the Background Sync API, even without users opening the app.
- Install prompts must be user-initiated — capture the beforeinstallprompt event, store it, and show it only when users show intent to install (not automatically).
- SPAs and PWAs are different concepts — SPA is a rendering architecture, PWA is a set of capabilities. An SPA can become a PWA, but an SPA isn’t automatically a PWA.
- Use Lighthouse for validation — Chrome DevTools Lighthouse provides a comprehensive PWA audit: which criteria are met and what needs fixing.