"src": "/static/assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
* **`display: "standalone"`**: Removes browser UI elements, providing a native app feel.
* **`purpose: "any maskable"`**: Allows the OS to apply adaptive icon shapes without clipping critical content.
* **`scope`**: Restricts the PWA context to `/dashboard`. Navigations outside this scope open in a standard browser tab.
#### 2. Service Worker Architecture
The Service Worker is a JavaScript file that runs in a separate thread. It intercepts network requests and manages the Cache API. A production-grade service worker must handle installation, activation (cleanup), and fetch events.
**Architecture Decision:** Use a versioned cache strategy. This allows the service worker to distinguish between old and new assets during updates, enabling safe cleanup of obsolete caches.
```typescript
// service-worker.ts
// Compiled to JS for browser execution
const CACHE_VERSION = 'v2.4.0';
const SHELL_CACHE_NAME = `logitrack-shell-${CACHE_VERSION}`;
const DATA_CACHE_NAME = `logitrack-data-${CACHE_VERSION}`;
const SHELL_ASSETS = [
'/',
'/dashboard',
'/static/assets/css/main.css',
'/static/assets/js/app.bundle.js',
'/static/assets/icons/icon-192x192.png'
];
// INSTALL: Pre-cache the App Shell
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(SHELL_CACHE_NAME).then((cache) => {
console.log('[SW] Caching shell assets');
return cache.addAll(SHELL_ASSETS);
}).catch((error) => {
console.error('[SW] Shell cache failed:', error);
})
);
// Force activation to avoid waiting for tab close
self.skipWaiting();
});
// ACTIVATE: Clean up old caches
self.addEventListener('activate', (event: ExtendableEvent) => {
const expectedCacheNames = [SHELL_CACHE_NAME, DATA_CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (!expectedCacheNames.includes(cacheName)) {
console.log('[SW] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
// Claim clients immediately to control open tabs
return self.clients.claim();
});
// FETCH: Network-First for data, Cache-First for shell
self.addEventListener('fetch', (event: FetchEvent) => {
const requestUrl = new URL(event.request.url);
// Strategy: Cache-First for static assets
if (requestUrl.origin === location.origin && SHELL_ASSETS.includes(requestUrl.pathname)) {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
return cachedResponse || fetch(event.request);
})
);
return;
}
// Strategy: Network-First for API calls
if (requestUrl.pathname.startsWith('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseClone = response.clone();
caches.open(DATA_CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
return;
}
// Default: Network with fallback
event.respondWith(fetch(event.request));
});
skipWaiting(): Forces the new service worker to activate immediately, reducing update latency.
clients.claim(): Ensures the new service worker controls all open pages immediately after activation.
- Dual Strategy: Static assets use Cache-First for speed; API endpoints use Network-First to ensure data freshness, falling back to cache only on failure.
3. Registration and Integration
The service worker must be registered from the main thread. Using TypeScript ensures type safety and modern async patterns.
// pwa-registration.ts
export async function initializePWA(): Promise<void> {
if (!('serviceWorker' in navigator)) {
console.warn('Service Workers are not supported in this environment.');
return;
}
try {
const registration = await navigator.serviceWorker.register('/static/assets/js/service-worker.js', {
scope: '/dashboard'
});
console.log('[PWA] Service Worker registered:', registration.scope);
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Notify user of available update
dispatchUpdateEvent();
}
});
}
});
} catch (error) {
console.error('[PWA] Registration failed:', error);
}
}
function dispatchUpdateEvent(): void {
const event = new CustomEvent('pwa-update-available');
window.dispatchEvent(event);
}
- Scope Alignment: The registration scope must match the manifest
scope to ensure consistent behavior.
- Update Handling: Listens for
updatefound to trigger a UI notification, allowing users to refresh and apply updates gracefully.
4. Backend Serving Configuration
The backend must serve the manifest and service worker with correct MIME types. In a FastAPI environment, static files are mounted to ensure proper routing.
# main.py
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
app = FastAPI()
# Mount static assets
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json")
async def get_manifest():
return FileResponse("static/manifest.json", media_type="application/json")
@app.get("/service-worker.js")
async def get_service_worker():
return FileResponse(
"static/assets/js/service-worker.js",
media_type="application/javascript",
headers={"Cache-Control": "no-cache"}
)
- Cache-Control Header: The service worker file must be served with
no-cache or no-store to ensure the browser always checks for updates. Caching the SW file itself would prevent updates from being detected.
- MIME Types: Explicitly setting
application/json and application/javascript prevents browser rejection due to incorrect content types.
Pitfall Guide
Implementing PWAs introduces complexity around caching and lifecycle management. The following pitfalls are common in production environments and include remediation strategies.
-
HTTPS Requirement Violation
- Explanation: Service Workers require a secure context. They will not register on HTTP origins except
localhost.
- Fix: Ensure the deployment environment uses HTTPS. For local development, use
localhost or configure a self-signed certificate trusted by the browser.
-
Cache Bloat and Stale Data
- Explanation: Failing to clean up old caches during activation leads to storage exhaustion. Additionally, caching API responses indefinitely results in stale data.
- Fix: Implement an
activate handler that deletes caches not matching the current version. Use separate caches for shell and data, and apply Network-First strategies for dynamic content.
-
Service Worker Scope Mismatch
- Explanation: If the registration scope differs from the manifest scope, the browser may exhibit inconsistent behavior, such as opening the PWA in a tab instead of standalone mode.
- Fix: Align the
scope in manifest.json, the registration call, and the start_url. Ensure the service worker file is located at or above the scope root.
-
Blocking the Install Event
- Explanation: Errors during the
install event can prevent the service worker from activating. If event.waitUntil rejects, the installation fails.
- Fix: Wrap cache operations in
try/catch blocks. Log errors clearly. Consider pre-caching only critical assets and deferring non-essential resources to the activate event or runtime caching.
-
Update Latency for Users
- Explanation: New service workers wait for existing tabs to close before activating. Users may remain on old versions indefinitely.
- Fix: Use
self.skipWaiting() in the install event and self.clients.claim() in the activate event. Alternatively, implement a UI prompt that calls registration.waiting.postMessage({type: 'SKIP_WAITING'}) to trigger an update on user action.
-
Caching the Service Worker File
- Explanation: If the browser caches the
service-worker.js file, it will not detect updates, locking users into an old version.
- Fix: Configure the server to send
Cache-Control: no-cache for the service worker file. Use a build step to generate unique filenames or query parameters if necessary, though the header approach is preferred.
-
Missing beforeinstallprompt Handling
- Explanation: Browsers suppress the install prompt automatically. Without handling the
beforeinstallprompt event, users cannot install the app.
- Fix: Listen for
beforeinstallprompt, prevent default, and store the event. Trigger the prompt via a user gesture (e.g., an "Install" button) by calling promptEvent.prompt().
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Content-Heavy Blog | Cache-First Strategy | Assets rarely change; speed is priority. | Low bandwidth; high cache storage. |
| Transactional Dashboard | Network-First for APIs | Data freshness is critical; cache as fallback. | Moderate bandwidth; ensures accuracy. |
| Low-Bandwidth Regions | Aggressive Pre-caching | Pre-cache all shell and static assets. | Higher initial load; better offline UX. |
| Frequent Updates | skipWaiting + UI Prompt | Users need latest features immediately. | Slightly higher complexity; better engagement. |
| Large Media Assets | Runtime Caching | Pre-caching large files wastes storage. | Optimized storage; on-demand loading. |
Configuration Template
Manifest Template (manifest.json)
{
"name": "{{APP_NAME}}",
"short_name": "{{SHORT_NAME}}",
"description": "{{APP_DESCRIPTION}}",
"start_url": "{{START_URL}}",
"scope": "{{SCOPE}}",
"display": "standalone",
"background_color": "{{BACKGROUND_COLOR}}",
"theme_color": "{{THEME_COLOR}}",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Service Worker Template (service-worker.js)
const CACHE_VERSION = 'v1.0.0';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const ASSETS = ['/index.html', '/styles.css', '/app.js'];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(SHELL_CACHE).then(c => c.addAll(ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== SHELL_CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);
});
Quick Start Guide
- Create Manifest: Add
manifest.json to your static assets directory with required fields and icons.
- Write Service Worker: Implement
service-worker.js with install, activate, and fetch handlers. Use versioned caches.
- Register in HTML: Add
<link rel="manifest" href="/manifest.json"> to your HTML head. Include registration script in your main JS bundle.
- Serve Correctly: Configure your backend to serve the manifest as
application/json and the service worker as application/javascript with no-cache headers.
- Test: Open DevTools, go to Application > Service Workers, and verify registration. Use the Offline checkbox to test cache behavior.