Progressive Web Apps Interview Questions (Free Preview)
Free sample of 15 from 39 questions available
Service Workers
What is a Service Worker and how does it work?
The 30-Second Answer: A Service Worker is a JavaScript file that runs in the background, separate from web pages, acting as a programmable network proxy between your web application and the network. It intercepts network requests, manages caching strategies, and enables offline functionality, push notifications, and background sync.
The 2-Minute Answer (If They Want More): Service Workers are event-driven workers that operate on a separate thread from the main JavaScript execution context. They can't access the DOM directly but communicate with pages through the postMessage API. Once registered, a Service Worker goes through a specific lifecycle and can intercept and handle network requests made by your application.
The core power of Service Workers lies in their ability to intercept fetch events. When your application makes any network request, the Service Worker can choose to respond with cached content, fetch from the network, or implement custom logic combining both approaches. This makes them essential for Progressive Web Apps (PWAs), enabling offline-first experiences.
Service Workers are persistent - once installed, they remain active across browser sessions until explicitly unregistered. They're also scope-based, meaning a Service Worker can only control pages within its registered scope (typically determined by its location in your directory structure).
One critical aspect is that Service Workers operate on a promise-based API, making all operations asynchronous. They wake up when needed to handle events (like fetch, push, or sync) and can be terminated by the browser when idle to save resources.
Code Example:
// Basic Service Worker structure (sw.js)
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
];
// Install event - cache resources
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Cache opened');
return cache.addAll(urlsToCache);
})
);
});
// Fetch event - intercept network requests
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version or fetch from network
return response || fetch(event.request);
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});
Mermaid Diagram:
flowchart TD
A[Web Page] -->|Register| B[Service Worker]
B -->|Intercepts| C[Fetch Request]
C -->|Check| D{Cache?}
D -->|Hit| E[Return Cached Response]
D -->|Miss| F[Fetch from Network]
F -->|Cache| G[Store in Cache]
G --> H[Return Network Response]
E --> I[Deliver to Page]
H --> I
I --> A
References:
- MDN Web Docs: Service Worker API
- Google Developers: Service Workers Introduction
- W3C Service Workers Specification
What is the difference between a Service Worker and a Web Worker?
The 30-Second Answer: Service Workers act as network proxies that can intercept and handle network requests, persist across sessions, and enable offline functionality for web applications. Web Workers are general-purpose background threads for running CPU-intensive tasks without blocking the main thread, but they can't intercept network requests and don't persist beyond the page session.
The 2-Minute Answer (If They Want More): While both Service Workers and Web Workers run JavaScript on separate threads from the main execution context, their purposes and capabilities are fundamentally different. Web Workers are designed for parallel computation - they let you offload heavy processing (like image manipulation, data parsing, or complex calculations) to prevent UI freezing. They're tied to the page that created them and terminate when the page closes.
Service Workers, on the other hand, are event-driven and designed for network interception and offline support. They persist across browser sessions, can wake up to handle events even when no pages are open (for push notifications or background sync), and have access to specialized APIs like the Cache API and Push API. A single Service Worker can control multiple pages within its scope.
Web Workers have direct communication with their parent page via postMessage(), while Service Workers communicate with pages through the same mechanism but in a more decoupled manner. Web Workers can be created and destroyed as needed by the page, whereas Service Workers follow a specific lifecycle managed by the browser.
Security requirements also differ: Service Workers require HTTPS (except on localhost) because they can intercept network traffic, while Web Workers can run on any protocol. Scope is another key difference - Service Workers have a defined scope that determines which pages they control, while Web Workers are simply owned by the page that created them.
Code Example:
// WEB WORKER EXAMPLE
// main.js - Creating and using a Web Worker
const worker = new Worker('worker.js');
// Send data to worker
worker.postMessage({ data: [1, 2, 3, 4, 5], operation: 'sum' });
// Receive result from worker
worker.addEventListener('message', (event) => {
console.log('Result from worker:', event.data); // 15
});
// Terminate worker when done
worker.terminate();
// worker.js - Web Worker file
self.addEventListener('message', (event) => {
const { data, operation } = event.data;
let result;
if (operation === 'sum') {
result = data.reduce((acc, val) => acc + val, 0);
}
// Send result back to main thread
self.postMessage(result);
});
// ---
// SERVICE WORKER EXAMPLE
// main.js - Registering a Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered');
// Send message to Service Worker
navigator.serviceWorker.controller?.postMessage({
type: 'CACHE_URLS',
urls: ['/api/data']
});
});
// Receive messages from Service Worker
navigator.serviceWorker.addEventListener('message', (event) => {
console.log('Message from SW:', event.data);
});
}
// sw.js - Service Worker file
// Intercepts network requests
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => response || fetch(event.request))
);
});
// Handles messages from pages
self.addEventListener('message', (event) => {
if (event.data.type === 'CACHE_URLS') {
caches.open('dynamic-cache')
.then((cache) => cache.addAll(event.data.urls));
}
});
// Can handle push notifications (Web Workers cannot)
self.addEventListener('push', (event) => {
const options = {
body: event.data.text(),
icon: '/icon.png'
};
event.waitUntil(
self.registration.showNotification('New Message', options)
);
});
Mermaid Diagram:
flowchart TD
subgraph "Web Worker"
A[Main Thread] -->|postMessage| B[Web Worker Thread]
B -->|postMessage| A
B -->|CPU-intensive tasks| C[Computation]
D[Page Close] --> E[Worker Terminated]
end
subgraph "Service Worker"
F[Multiple Pages] -->|register/message| G[Service Worker]
G -->|Intercepts| H[Network Requests]
H --> I[Cache API]
G -->|Persists| J[Across Sessions]
G -->|Handles| K[Push/Sync Events]
L[Page Close] -.->|SW persists| G
end
style B fill:#e1f5ff
style G fill:#fff4e1
Comparison Table:
| Feature | Service Worker | Web Worker |
|---|---|---|
| Purpose | Network proxy, offline support | Parallel computation |
| Lifecycle | Persists across sessions | Lives with page |
| Network Interception | Yes (fetch events) | No |
| Cache API | Yes | No |
| Push Notifications | Yes | No |
| Background Sync | Yes | No |
| Scope | Controls multiple pages | Owned by one page |
| HTTPS Required | Yes (except localhost) | No |
| DOM Access | No | No |
| Typical Use Cases | PWAs, offline apps, caching | Heavy calculations, data processing |
References:
↑ Back to topWhat is the Service Worker lifecycle (install, activate, fetch)?
The 30-Second Answer: The Service Worker lifecycle consists of three main phases: install (when the SW is first downloaded and cached), activate (when it takes control and cleans up old versions), and fetch (the ongoing phase where it intercepts network requests). This lifecycle ensures smooth updates without breaking active pages.
The 2-Minute Answer (If They Want More): The Service Worker lifecycle is carefully designed to ensure that updates don't disrupt currently running applications. When you register a Service Worker for the first time, it enters the installing state. During this phase, the install event fires, which is the ideal time to cache static assets. The installation only completes successfully if all resources are cached properly.
After successful installation, the Service Worker enters a waiting state if there's already an active Service Worker controlling the page. This prevents conflicts between different versions. The new Service Worker won't activate until all pages controlled by the old Service Worker are closed. You can bypass this wait using skipWaiting(), but this should be used carefully.
The activate event fires when the Service Worker finally takes control. This is the perfect time to clean up old caches from previous versions. Once activated, the Service Worker enters the activated state and can handle functional events like fetch, push, and sync.
The fetch event isn't part of the lifecycle per se, but rather the primary functional event. It fires whenever any resource is fetched from the Service Worker's scope. This is where you implement caching strategies, offline functionality, and other network interception logic. The lifecycle ensures that only one version of your Service Worker handles fetch events at a time.
Code Example:
// Complete lifecycle demonstration
const CACHE_VERSION = 'v2';
const CACHE_NAME = `my-app-${CACHE_VERSION}`;
// INSTALL - Happens first for new Service Workers
self.addEventListener('install', (event) => {
console.log('[SW] Install event');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
return cache.addAll([
'/',
'/app.js',
'/styles.css',
'/offline.html'
]);
})
.then(() => {
// Force the waiting Service Worker to become active
// Use with caution - can break pages using old version
return self.skipWaiting();
})
);
});
// ACTIVATE - Happens after install (or after pages close)
self.addEventListener('activate', (event) => {
console.log('[SW] Activate event');
event.waitUntil(
// Clean up old caches
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
// Take control of all pages immediately
// Without this, SW only controls pages on next load
return self.clients.claim();
})
);
});
// FETCH - Ongoing functional event
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request)
.then((response) => {
// Cache successful responses
if (response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Return offline page for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
});
})
);
});
Mermaid Diagram:
stateDiagram-v2
[*] --> Parsed: navigator.serviceWorker.register()
Parsed --> Installing: Download SW file
Installing --> Installed: install event completes
Installing --> Redundant: install fails
Installed --> Waiting: Old SW still active
Installed --> Activating: No active SW / skipWaiting()
Waiting --> Activating: Old SW terminated / all pages closed
Activating --> Activated: activate event completes
Activating --> Redundant: activate fails
Activated --> Activated: fetch, push, sync events
Activated --> Redundant: New SW activates / unregister()
Redundant --> [*]
References:
- MDN: Service Worker Lifecycle
- Google Developers: The Service Worker Lifecycle
- Jake Archibald: Service Worker Lifecycle Explained
PWA Fundamentals
What is the difference between a PWA and a native mobile app?
The 30-Second Answer: PWAs are web applications that run in the browser using standard web technologies, while native apps are platform-specific applications built for iOS, Android, etc. PWAs offer cross-platform compatibility and easier updates, but native apps have deeper system integration and access to more device features.
The 2-Minute Answer (If They Want More): The fundamental difference lies in the technology stack and distribution model. Native apps are built using platform-specific languages (Swift/Objective-C for iOS, Kotlin/Java for Android) and distributed through app stores. PWAs are built with web technologies (HTML, CSS, JavaScript) and distributed via URLs—users can access them through any browser without app store approval.
From a development perspective, PWAs offer significant advantages in cost and maintenance. A single PWA codebase works across all platforms, while native development typically requires separate teams for iOS and Android. Updates to PWAs are instant and automatic—users always get the latest version when they access the app. Native apps require users to download updates from app stores, leading to version fragmentation.
However, native apps still have advantages in certain areas. They can access lower-level device APIs that aren't available to web apps, such as advanced camera controls, Bluetooth LE, NFC, and deep system integrations. Native apps generally perform better for graphics-intensive applications or complex computations because they can directly access hardware acceleration. They also work completely offline from installation, while PWAs need at least one initial visit online to cache resources.
The performance gap is closing as browser capabilities improve. Modern PWAs can access geolocation, camera, microphone, push notifications, background sync, and more. For many use cases, particularly content-driven apps, business applications, and e-commerce, PWAs now provide an experience virtually indistinguishable from native apps while offering superior discoverability and zero-friction installation.
Mermaid Diagram:
flowchart TD
subgraph PWA["Progressive Web App"]
A1[Web Technologies]
A2[Browser Runtime]
A3[URL Distribution]
A4[Instant Updates]
A5[Cross-Platform]
end
subgraph Native["Native Mobile App"]
B1[Platform Languages]
B2[Native Runtime]
B3[App Store Distribution]
B4[Manual Updates]
B5[Platform-Specific]
end
C{User Needs} --> D{Deep Hardware Access?}
D -->|Yes| Native
D -->|No| E{Budget/Timeline}
E -->|Limited| PWA
E -->|Flexible| F{Target Audience}
F -->|Broad Reach| PWA
F -->|Platform-Specific| Native
style PWA fill:#4CAF50,color:#fff
style Native fill:#2196F3,color:#fff
Code Example:
// PWA Capability Check - Feature detection for progressive enhancement
class PWACapabilities {
static checkFeatures() {
return {
serviceWorker: 'serviceWorker' in navigator,
pushNotifications: 'PushManager' in window,
backgroundSync: 'sync' in ServiceWorkerRegistration.prototype,
installable: 'beforeinstallprompt' in window,
webShare: 'share' in navigator,
geolocation: 'geolocation' in navigator,
camera: 'mediaDevices' in navigator,
storage: 'storage' in navigator,
// Features still primarily native-only:
bluetooth: 'bluetooth' in navigator, // Limited support
nfc: 'NDEFReader' in window // Very limited
};
}
static async requestNotificationPermission() {
// PWA approach - requires user gesture and permission
if ('Notification' in window) {
const permission = await Notification.requestPermission();
return permission === 'granted';
}
return false;
}
}
// Usage in PWA
const capabilities = PWACapabilities.checkFeatures();
console.log('PWA Features Available:', capabilities);
// Progressive enhancement based on capabilities
if (capabilities.pushNotifications) {
// Enable push notification features
enablePushFeatures();
} else {
// Fallback to polling or other mechanisms
enablePollingFallback();
}
// Native iOS equivalent - direct API access without browser limitations
import UserNotifications
// Native approach - more direct system integration
class NativeCapabilities {
static func requestNotificationPermission() {
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
// Direct system API access
print("Permission granted: \(granted)")
}
}
// Native apps can access APIs not available to PWAs
static func accessBluetoothLE() {
// Full CoreBluetooth framework access
// Much more control than Web Bluetooth API
}
}
References:
↑ Back to topWhat is a Progressive Web App (PWA)?
The 30-Second Answer: A Progressive Web App (PWA) is a web application that uses modern web capabilities to deliver an app-like experience to users. It combines the reach of the web with the functionality of native apps, allowing users to install it on their devices, work offline, and receive push notifications.
The 2-Minute Answer (If They Want More): Progressive Web Apps represent a paradigm shift in how we think about web applications. They're built using standard web technologies (HTML, CSS, JavaScript) but leverage modern browser APIs to provide features traditionally associated with native mobile apps.
The "progressive" in PWA means the app works for every user, regardless of browser choice, because it's built with progressive enhancement as a core principle. A user on a modern browser gets the full experience with offline support and installability, while users on older browsers still get a functional web experience.
PWAs are defined by three technical requirements: they must be served over HTTPS for security, they must include a Web App Manifest (a JSON file describing the app's appearance and behavior), and they must implement a Service Worker (a script that enables offline functionality and background features). These components work together to create an experience that feels native while maintaining the web's inherent advantages of discoverability, linkability, and no app store gatekeepers.
The key differentiator is that PWAs are discoverable through search engines, shareable via URL, and don't require app store approval or installation from a marketplace—users can install directly from the browser.
Code Example:
// Basic Service Worker registration - the foundation of a PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered:', registration.scope);
})
.catch(error => {
console.log('Service Worker registration failed:', error);
});
});
}
// manifest.json - Defines how the PWA appears when installed
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Mermaid Diagram:
flowchart TD
A[Progressive Web App] --> B[Web Technologies]
A --> C[Service Worker]
A --> D[Web App Manifest]
A --> E[HTTPS]
B --> F[HTML/CSS/JS]
C --> G[Offline Support]
C --> H[Background Sync]
C --> I[Push Notifications]
D --> J[Installability]
D --> K[App-like Appearance]
E --> L[Security & Trust]
style A fill:#2196F3,color:#fff
style C fill:#4CAF50,color:#fff
style D fill:#FF9800,color:#fff
References:
- Progressive Web Apps - web.dev
- MDN: Progressive Web Apps
- Google Developers: What are Progressive Web Apps?
What are the core principles of a PWA (reliable, fast, engaging)?
The 30-Second Answer: PWAs are built on three core principles: Reliable (load instantly and work offline), Fast (respond quickly to user interactions), and Engaging (feel like a native app with immersive user experience). These principles ensure users get a high-quality experience regardless of network conditions.
The 2-Minute Answer (If They Want More): The three core principles form the foundation of what makes a PWA successful and differentiate it from traditional web applications.
Reliable means the app loads instantly and never shows the browser's "no internet" error page, even on uncertain network conditions. This is achieved through Service Workers that cache critical resources and can serve content offline. Users should be able to open your app and see meaningful content immediately, whether they're on a fast connection, slow 3G, or completely offline.
Fast encompasses both the initial load time and runtime performance. PWAs should respond to user interactions in under 100ms and achieve a smooth 60fps animation rate. This involves optimizing asset delivery, using lazy loading, minimizing JavaScript execution time, and leveraging browser caching strategies. Fast also means quick navigation between pages and instant feedback on user actions.
Engaging means the app provides an immersive, app-like experience that keeps users coming back. This includes features like home screen installation, full-screen display modes, push notifications for re-engagement, and smooth animations. The app should feel integrated with the device rather than feeling like a website trapped in a browser window. Engaging also encompasses good UX design, meaningful micro-interactions, and features that add value to the user's workflow.
Together, these principles create an experience that rivals or exceeds native apps while maintaining the web's unique advantages of instant updates, universal access, and no installation friction.
Mermaid Diagram:
flowchart LR
A[PWA Core Principles] --> B[Reliable]
A --> C[Fast]
A --> D[Engaging]
B --> B1[Offline Support]
B --> B2[Service Worker Cache]
B --> B3[Network Independence]
C --> C1[Quick Load < 3s]
C --> C2[Smooth Interactions]
C --> C3[60fps Performance]
D --> D1[App-like UX]
D --> D2[Push Notifications]
D --> D3[Home Screen Install]
style A fill:#2196F3,color:#fff
style B fill:#4CAF50,color:#fff
style C fill:#FF9800,color:#fff
style D fill:#9C27B0,color:#fff
Code Example:
// Service Worker demonstrating RELIABLE principle - offline caching
const CACHE_NAME = 'my-pwa-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/main.js',
'/offline.html'
];
// Install event - cache critical resources
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
.catch(() => caches.match('/offline.html'))
);
});
// Demonstrating FAST principle - code splitting and lazy loading
// Load critical features immediately, defer non-critical code
const loadFeature = async (featureName) => {
// Dynamic import for code splitting
const module = await import(`./features/${featureName}.js`);
return module.default;
};
// Load only when needed
document.getElementById('advanced-button').addEventListener('click', async () => {
const advancedFeature = await loadFeature('advanced');
advancedFeature.initialize();
});
// Demonstrating ENGAGING principle - push notification subscription
async function subscribeUserToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
});
// Send subscription to server
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
console.log('User subscribed to push notifications');
}
References:
↑ Back to topWhat are the advantages and disadvantages of PWAs?
The 30-Second Answer: PWAs offer cross-platform development, instant updates, no app store approval, and web discoverability, making them cost-effective and easy to maintain. However, they have limited access to some native device features, potential performance constraints for intensive tasks, and varying browser support across platforms.
The 2-Minute Answer (If They Want More): Advantages:
The primary advantage is development efficiency—one codebase serves all platforms (iOS, Android, desktop, web). This dramatically reduces development and maintenance costs compared to maintaining separate native codebases. Updates are instant and automatic; when I deploy a fix or new feature, every user gets it immediately without needing to download an update or wait for app store approval.
PWAs are inherently discoverable through search engines, making them accessible via URLs that can be shared, bookmarked, and indexed. There's zero installation friction—users can try the app instantly and choose to install it later if they find it valuable. This significantly lowers the barrier to user acquisition compared to convincing someone to download a multi-megabyte native app.
From a business perspective, PWAs bypass app store commissions (typically 15-30%) and distribution control. They're also more accessible for users with limited device storage, as PWAs generally have smaller footprints than native apps.
Disadvantages:
The most significant limitation is restricted access to certain native APIs. While the gap is closing with initiatives like Project Fugu, some advanced features (advanced Bluetooth operations, comprehensive background processing, deep system integrations) remain native-only or have limited support in PWAs.
Browser support varies significantly, particularly on iOS where Apple has historically been slower to implement PWA features. Safari on iOS doesn't support all PWA capabilities that Chrome on Android does, creating a fragmented experience. Push notifications on iOS only gained support in iOS 16.4 (2023), years behind Android.
For graphics-intensive applications, games, or apps requiring heavy computation, native apps still have performance advantages with direct hardware access. PWAs also can't be pre-installed on devices or deeply integrated into OS-level features like native apps can.
Mermaid Diagram:
flowchart TB
subgraph Advantages["âś“ PWA Advantages"]
A1[Single Codebase<br/>All Platforms]
A2[Instant Updates<br/>No Downloads]
A3[Web Discoverability<br/>SEO Benefits]
A4[No App Store<br/>Approval/Fees]
A5[Low Installation<br/>Friction]
A6[Smaller App Size<br/>Storage Friendly]
end
subgraph Disadvantages["âś— PWA Disadvantages"]
D1[Limited Native<br/>API Access]
D2[Inconsistent Browser<br/>Support iOS/Android]
D3[Performance Limits<br/>Graphics/Compute]
D4[No Pre-installation<br/>by OEMs]
D5[Offline Requires<br/>Initial Visit]
D6[Limited System<br/>Integration]
end
Decision{Choose PWA?}
Decision -->|Content/Business App| Advantages
Decision -->|Game/Advanced Hardware| Disadvantages
style Advantages fill:#4CAF50,color:#fff
style Disadvantages fill:#f44336,color:#fff
style Decision fill:#FF9800,color:#fff
Code Example:
// Advantages in action - Cross-platform feature with progressive enhancement
class CrossPlatformFeatures {
// Single codebase works everywhere
static async enableOfflineSupport() {
if ('serviceWorker' in navigator) {
// Advantage: Same code works on Chrome (Android/Desktop) and Safari (iOS/macOS)
await navigator.serviceWorker.register('/sw.js');
console.log('âś“ Offline support enabled across all platforms');
}
}
// Advantage: Instant updates - no app store approval needed
static async checkForUpdates() {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
// Update happens automatically on next page load
registration.update();
console.log('âś“ Checking for instant updates - no user action needed');
}
}
// Disadvantage: Feature detection needed for inconsistent browser support
static async enablePushNotifications() {
// Works on Chrome Android, but iOS Safari limited until 16.4+
if ('PushManager' in window && 'Notification' in window) {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('âś“ Push enabled (where supported)');
}
} else {
console.log('âś— Push notifications not available on this browser/OS');
// Fallback strategy needed
this.enablePollingFallback();
}
}
// Disadvantage: Some native features unavailable or limited
static async advancedBluetoothAccess() {
if ('bluetooth' in navigator) {
// Web Bluetooth API exists but limited compared to native
// Cannot: Run in background, access all Bluetooth profiles, etc.
console.log('âš Limited Bluetooth access compared to native apps');
} else {
console.log('âś— Advanced Bluetooth requires native app');
}
}
// Advantage: Easy A/B testing and instant rollback
static applyFeatureFlag(featureName) {
// Change features server-side, users get updates immediately
fetch('/api/features')
.then(res => res.json())
.then(features => {
if (features[featureName]) {
console.log('âś“ New feature enabled - no app update required');
}
});
}
static enablePollingFallback() {
// Fallback for missing push notification support
setInterval(() => this.checkForNewContent(), 300000); // Every 5 min
}
static async checkForNewContent() {
// Poll server for updates when push isn't available
}
}
// Usage
await CrossPlatformFeatures.enableOfflineSupport();
await CrossPlatformFeatures.enablePushNotifications();
References:
- PWA Benefits and Drawbacks - web.dev
- When to Build a PWA vs Native App
- PWA Platform Support - Can I Use
Offline Functionality
What is Background Sync and how does it work?
The 30-Second Answer: Background Sync is a Service Worker API that allows web apps to defer network operations until the user has stable connectivity. It registers sync events that fire when the browser detects a network connection, enabling reliable offline-first experiences where user actions are automatically retried without user intervention.
The 2-Minute Answer (If They Want More): Background Sync solves a critical problem in offline-first applications: ensuring that user actions (like sending messages, submitting forms, or uploading files) eventually complete even if the user closes the tab or browser. When you register a sync event, the browser guarantees it will fire when connectivity is available, even if your app isn't currently running.
The API works through the Service Worker. Your main application registers a sync event with a unique tag, and the Service Worker listens for the 'sync' event. When the browser determines that connectivity is available, it wakes up the Service Worker and fires the sync event, allowing you to process pending operations.
There are two types: one-off sync (Background Sync) and periodic sync (Periodic Background Sync). One-off sync fires once when connectivity is restored, perfect for queued user actions. Periodic sync allows apps to refresh content in the background at regular intervals, though it requires additional permissions and is more restricted.
The browser handles the retry logic intelligently—it won't fire sync events immediately on brief connectivity blips, and it implements exponential backoff automatically. If a sync event handler rejects or throws an error, the browser will retry later. However, browsers limit retry attempts and may eventually give up on persistently failing syncs.
This API dramatically improves user experience—users can submit forms, send messages, or save data while offline, then close the browser and trust that their actions will complete automatically when connectivity returns.
Code Example:
// main.js - Register a sync event
async function saveUserPost(postData) {
// Save to IndexedDB immediately for offline access
await savePostToIndexedDB(postData);
// Show optimistic UI
displayPost(postData);
// Register background sync
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
try {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-posts');
console.log('Background sync registered');
} catch (error) {
console.error('Background sync registration failed:', error);
// Fallback: try to sync immediately
await syncPosts();
}
} else {
// Browser doesn't support background sync
console.warn('Background sync not supported, syncing immediately');
await syncPosts();
}
}
// IndexedDB helpers
async function savePostToIndexedDB(post) {
const db = await openDB();
const tx = db.transaction(['pendingPosts'], 'readwrite');
const store = tx.objectStore('pendingPosts');
await store.add({
...post,
id: Date.now(),
synced: false,
timestamp: new Date().toISOString()
});
return tx.complete;
}
async function getPendingPosts() {
const db = await openDB();
const tx = db.transaction(['pendingPosts'], 'readonly');
const store = tx.objectStore('pendingPosts');
const posts = await store.getAll();
return posts.filter(post => !post.synced);
}
async function markPostAsSynced(postId) {
const db = await openDB();
const tx = db.transaction(['pendingPosts'], 'readwrite');
const store = tx.objectStore('pendingPosts');
await store.delete(postId);
return tx.complete;
}
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('PostsDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('pendingPosts')) {
db.createObjectStore('pendingPosts', { keyPath: 'id' });
}
};
});
}
// service-worker.js - Handle sync event
self.addEventListener('sync', (event) => {
console.log('Sync event fired:', event.tag);
if (event.tag === 'sync-posts') {
event.waitUntil(syncPosts());
}
});
async function syncPosts() {
try {
const pendingPosts = await getPendingPosts();
console.log(`Syncing ${pendingPosts.length} pending posts`);
const syncPromises = pendingPosts.map(async (post) => {
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(post)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Mark as synced and remove from IndexedDB
await markPostAsSynced(post.id);
console.log('Post synced successfully:', post.id);
// Notify user
await showNotification('Post published!', {
body: post.title,
icon: '/icons/success.png'
});
return true;
} catch (error) {
console.error('Failed to sync post:', error);
// Don't mark as synced - will retry later
return false;
}
});
const results = await Promise.allSettled(syncPromises);
const failedCount = results.filter(r => r.value === false).length;
if (failedCount > 0) {
// Throw error to trigger browser retry
throw new Error(`${failedCount} posts failed to sync`);
}
return true;
} catch (error) {
console.error('Sync failed:', error);
throw error; // Browser will retry
}
}
async function showNotification(title, options) {
const registration = await self.registration;
return registration.showNotification(title, options);
}
// Periodic Background Sync (if supported)
async function registerPeriodicSync() {
if ('periodicSync' in ServiceWorkerRegistration.prototype) {
try {
const registration = await navigator.serviceWorker.ready;
// Request permission if needed
const status = await navigator.permissions.query({
name: 'periodic-background-sync'
});
if (status.state === 'granted') {
await registration.periodicSync.register('sync-content', {
minInterval: 24 * 60 * 60 * 1000 // 24 hours
});
console.log('Periodic sync registered');
}
} catch (error) {
console.error('Periodic sync registration failed:', error);
}
}
}
// In service worker - handle periodic sync
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'sync-content') {
event.waitUntil(refreshContent());
}
});
async function refreshContent() {
try {
const response = await fetch('/api/latest-content');
const content = await response.json();
// Cache the fresh content
const cache = await caches.open('content-cache-v1');
await cache.put('/api/latest-content', new Response(JSON.stringify(content)));
console.log('Content refreshed in background');
} catch (error) {
console.error('Background content refresh failed:', error);
}
}
Mermaid Diagram:
sequenceDiagram
participant User
participant App
participant SW as Service Worker
participant IDB as IndexedDB
participant Browser
participant Server
User->>App: Submit form (offline)
App->>IDB: Save data locally
App->>SW: Register sync event
SW->>Browser: Queue sync task
Browser-->>User: Confirmation saved
Note over User,Browser: User closes tab/browser
Browser->>Browser: Detects connectivity
Browser->>SW: Fire sync event
SW->>IDB: Get pending data
IDB-->>SW: Return pending items
loop For each pending item
SW->>Server: POST data
alt Success
Server-->>SW: 200 OK
SW->>IDB: Mark as synced
SW->>User: Show notification
else Failure
Server-->>SW: Error
SW->>Browser: Throw error (retry later)
end
end
Browser->>SW: Retry if needed
References:
- Background Sync API - MDN
- Introducing Background Sync - Google Developers
- Periodic Background Sync API
Web App Manifest
What is a Web App Manifest?
The 30-Second Answer: A Web App Manifest is a JSON file that provides metadata about a Progressive Web App, enabling it to be installed on a user's device and behave like a native application. It defines the app's name, icons, colors, display mode, and other properties that control how the app appears when launched from the home screen.
The 2-Minute Answer (If They Want More):
The Web App Manifest is the foundation of installability for Progressive Web Apps. It's a simple JSON file (typically named manifest.json or site.webmanifest) that you link to from your HTML using a <link> tag in the <head> section. This file tells browsers and operating systems everything they need to know to treat your web app like a native application.
When a user installs your PWA, the browser reads the manifest to determine what icon to place on their home screen, what name to display beneath it, what color to use for the splash screen, and how the app should behave when launched (full screen, standalone window, or minimal browser UI). The manifest also enables advanced features like defining shortcuts, handling file types, and specifying related applications.
The manifest works hand-in-hand with service workers to create the complete PWA experience. While service workers handle offline functionality and caching, the manifest handles the visual presentation and installation behavior. Without a properly configured manifest, your PWA won't be installable, even if it has a service worker.
Modern browsers automatically detect the manifest and may prompt users to install the app if certain criteria are met (HTTPS, service worker registered, manifest present with required fields). This installability is what distinguishes a PWA from a regular website.
Code Example:
<!-- Link to manifest in HTML <head> -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My PWA</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#3367D6">
</head>
<body>
<!-- App content -->
</body>
</html>
// manifest.json - Basic example
{
"name": "My Progressive Web App",
"short_name": "My PWA",
"description": "A powerful PWA demonstrating modern web capabilities",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3367D6",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Mermaid Diagram:
flowchart TD
A[User Visits PWA] --> B{Installability Criteria Met?}
B -->|Yes| C[Browser Reads manifest.json]
B -->|No| D[Regular Web Experience]
C --> E[Extract Metadata]
E --> F[App Name & Icons]
E --> G[Display Mode & Colors]
E --> H[Start URL & Orientation]
F --> I[Show Install Prompt]
G --> I
H --> I
I --> J[User Installs]
J --> K[App Added to Home Screen]
K --> L[Launch as Standalone App]
References:
- MDN Web Docs: Web App Manifest
- W3C Web App Manifest Specification
- Google Developers: Add a Web App Manifest
Caching Strategies
What is the Cache-First strategy and when would you use it?
The 30-Second Answer: Cache-First checks the cache before the network, serving cached content immediately if available. I use it for static assets like CSS, JavaScript, fonts, and images that don't change frequently—it provides the fastest possible response and excellent offline support.
The 2-Minute Answer (If They Want More): Cache-First (also called Cache-Falling-Back-to-Network) prioritizes speed and offline capability by checking the cache before making any network requests. When a request comes in, the service worker first looks in the cache. If found, it returns the cached response immediately—no network delay. Only if the resource isn't cached does it fetch from the network.
This strategy is perfect for versioned static assets like app.v123.js or styles.v456.css because once cached, these files never change (the URL changes instead). It's also excellent for web fonts, which are static and block rendering if not available quickly. The strategy provides instant loading on repeat visits and complete offline functionality for cached resources.
The main tradeoff is freshness—users might see outdated content if the cache isn't properly invalidated. That's why Cache-First works best with versioned assets or resources where staleness is acceptable. For dynamic content or APIs, Network-First or Stale-While-Revalidate are better choices.
I typically combine Cache-First with cache versioning, so when I deploy updates, the service worker update triggers a new cache version, ensuring users eventually get fresh content without sacrificing performance.
Code Example:
// Cache-First implementation
const CACHE_NAME = 'static-assets-v1';
self.addEventListener('install', (event) => {
// Pre-cache critical static assets during installation
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/css/styles.css',
'/js/app.js',
'/fonts/roboto.woff2',
'/images/logo.svg',
'/offline.html'
]);
})
);
});
self.addEventListener('fetch', (event) => {
// Apply Cache-First to static assets
if (isStaticAsset(event.request)) {
event.respondWith(cacheFirst(event.request));
}
});
async function cacheFirst(request) {
// 1. Check cache first
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
console.log('Serving from cache:', request.url);
return cachedResponse;
}
// 2. If not in cache, fetch from network
try {
console.log('Fetching from network:', request.url);
const networkResponse = await fetch(request);
// 3. Cache the fetched resource for future use
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('Fetch failed:', error);
// 4. Optionally return a fallback page
if (request.destination === 'document') {
return cache.match('/offline.html');
}
throw error;
}
}
function isStaticAsset(request) {
const url = new URL(request.url);
const staticExtensions = ['.css', '.js', '.woff2', '.woff', '.ttf', '.svg', '.png', '.jpg', '.jpeg'];
return staticExtensions.some(ext => url.pathname.endsWith(ext));
}
Mermaid Diagram:
flowchart TD
A[Request for Static Asset] --> B[Service Worker Intercepts]
B --> C{Check Cache}
C -->|Found| D[Return Cached Response]
D --> E[Fast Response - No Network Delay]
C -->|Not Found| F[Fetch from Network]
F --> G{Network Success?}
G -->|Yes| H[Store in Cache]
H --> I[Return Network Response]
G -->|No| J{Critical Resource?}
J -->|Yes - HTML| K[Return Offline Page]
J -->|No| L[Return Error]
style D fill:#90EE90
style E fill:#90EE90
style K fill:#FFB6C1
style L fill:#FFB6C1
References:
↑ Back to topWhat is the Network-First strategy and when would you use it?
The 30-Second Answer: Network-First attempts to fetch fresh content from the network first, falling back to cache only when offline or when the network fails. I use it for API responses, user-generated content, and news feeds where data freshness is critical but offline access is still valuable.
The 2-Minute Answer (If They Want More): Network-First (also called Network-Falling-Back-to-Cache) prioritizes freshness over speed by always attempting to fetch the latest content from the network. The service worker tries the network first, and only if that fails—due to being offline, slow network, or server errors—does it fall back to cached content. This ensures users see the freshest possible data while still providing graceful degradation when offline.
This strategy is ideal for API endpoints that return frequently changing data, social media feeds, news articles, or any content where showing stale data is suboptimal but acceptable as a fallback. For example, a weather app should show current conditions when possible, but showing yesterday's weather is better than showing nothing at all.
The tradeoff is performance—every request waits for a network response, adding latency even when you have perfectly good cached content. To mitigate this, I often add a timeout (network race condition) where if the network doesn't respond within 3-5 seconds, I serve the cache and update it in the background. This provides a balance between freshness and performance.
Network-First also means users always consume data when online, which is important to consider for users with limited data plans or slow connections. For these users, Stale-While-Revalidate might be more appropriate.
Code Example:
// Network-First implementation with timeout
const CACHE_NAME = 'dynamic-v1';
const TIMEOUT = 3000; // 3 seconds
self.addEventListener('fetch', (event) => {
// Apply Network-First to API calls
if (event.request.url.includes('/api/')) {
event.respondWith(networkFirst(event.request));
}
});
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
// Try network first with timeout
const networkResponse = await fetchWithTimeout(request, TIMEOUT);
// Update cache with fresh response
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
console.log('Served fresh from network:', request.url);
}
return networkResponse;
} catch (error) {
// Network failed, try cache
console.log('Network failed, trying cache:', request.url);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
console.log('Served from cache:', request.url);
// Add header to indicate stale data
const headers = new Headers(cachedResponse.headers);
headers.append('X-From-Cache', 'true');
return new Response(cachedResponse.body, {
status: cachedResponse.status,
statusText: cachedResponse.statusText,
headers: headers
});
}
// No cache available, return error
console.error('No cache available for:', request.url);
throw error;
}
}
function fetchWithTimeout(request, timeout) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Network timeout'));
}, timeout);
fetch(request)
.then((response) => {
clearTimeout(timeoutId);
resolve(response);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
// Advanced: Network-First with background sync for failed updates
async function networkFirstWithSync(request) {
const cache = await caches.open(CACHE_NAME);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await cache.match(request);
if (cached) {
// Register background sync to retry when online
if ('sync' in self.registration) {
await self.registration.sync.register('sync-data');
}
return cached;
}
throw error;
}
}
Mermaid Diagram:
flowchart TD
A[API Request] --> B[Service Worker Intercepts]
B --> C[Try Network First]
C --> D{Network Response?}
D -->|Success within Timeout| E[Update Cache]
E --> F[Return Fresh Data]
F --> G[User Sees Latest Content]
D -->|Timeout/Offline/Error| H{Check Cache}
H -->|Found| I[Return Cached Data]
I --> J[User Sees Stale Content]
J --> K[Add 'From Cache' Indicator]
H -->|Not Found| L[Return Error]
L --> M[Show Error Message]
style F fill:#90EE90
style G fill:#90EE90
style I fill:#FFE4B5
style J fill:#FFE4B5
style L fill:#FFB6C1
style M fill:#FFB6C1
References:
- Network-First Strategy - web.dev
- Workbox Network-First Strategy
- Progressive Web Apps Training - Google
What is the Stale-While-Revalidate strategy?
The 30-Second Answer: Stale-While-Revalidate serves cached content immediately for instant response, while simultaneously fetching fresh content in the background to update the cache for next time. It provides the best balance of speed and freshness—perfect for images, user profiles, and content that changes occasionally but doesn't need to be real-time.
The 2-Minute Answer (If They Want More): Stale-While-Revalidate (SWR) is a powerful strategy that combines the speed of Cache-First with the freshness of Network-First. When a request comes in, the service worker immediately returns the cached version (if available), giving users an instant response. Simultaneously, it fetches the latest version from the network in the background and updates the cache for future requests.
This means on the first visit after content updates, users see the old version but the new version is already being cached. On the second visit, they see the fresh content. For most web content—social media avatars, product images, article thumbnails, or infrequently changing data—this one-visit delay is perfectly acceptable and provides an excellent user experience.
The strategy is particularly effective for resources that change occasionally but not constantly. For example, a user's profile picture might update once a month, so showing the cached version instantly while updating in the background is ideal. However, for real-time data like stock prices or live sports scores, you'd want Network-First instead.
Modern HTTP also supports SWR through the Cache-Control: stale-while-revalidate header, which instructs browsers and CDNs to use this pattern. Service workers give you programmatic control over the same concept, allowing you to apply it selectively to different resource types regardless of server headers.
Code Example:
// Stale-While-Revalidate implementation
const CACHE_NAME = 'swr-v1';
self.addEventListener('fetch', (event) => {
// Apply SWR to images and avatar/profile requests
if (event.request.destination === 'image' ||
event.request.url.includes('/avatars/') ||
event.request.url.includes('/profiles/')) {
event.respondWith(staleWhileRevalidate(event.request));
}
});
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
// 1. Get cached response (don't wait for network)
const cachedResponse = await cache.match(request);
// 2. Start network fetch in background
const fetchPromise = fetch(request).then(async (networkResponse) => {
// Update cache with fresh response
if (networkResponse.ok) {
await cache.put(request, networkResponse.clone());
console.log('Cache updated in background:', request.url);
}
return networkResponse;
}).catch((error) => {
console.log('Background fetch failed:', error);
// Don't throw - the cached response was already returned
});
// 3. Return cached response immediately (or wait for network if no cache)
if (cachedResponse) {
console.log('Serving stale from cache:', request.url);
// Background fetch continues even after returning
return cachedResponse;
}
// No cache available, wait for network
console.log('No cache, waiting for network:', request.url);
return fetchPromise;
}
// Advanced: SWR with cache age limit
async function staleWhileRevalidateWithAge(request, maxAge = 86400000) {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
// Check if cache is too old
if (cachedResponse) {
const cachedDate = new Date(cachedResponse.headers.get('date'));
const age = Date.now() - cachedDate.getTime();
if (age > maxAge) {
console.log('Cache too old, forcing network fetch');
// Cache is stale, do network-first instead
try {
const response = await fetch(request);
cache.put(request, response.clone());
return response;
} catch (error) {
// Network failed, return old cache anyway
return cachedResponse;
}
}
}
// Standard SWR for fresh-enough cache
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
});
return cachedResponse || fetchPromise;
}
// Using Workbox (much simpler)
import { StaleWhileRevalidate } from 'workbox-strategies';
import { registerRoute } from 'workbox-routing';
registerRoute(
({request}) => request.destination === 'image',
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
{
cacheWillUpdate: async ({response}) => {
// Only cache successful responses
return response.status === 200 ? response : null;
}
}
]
})
);
Mermaid Diagram:
flowchart TD
A[Request for Image] --> B[Service Worker Intercepts]
B --> C{Check Cache}
C -->|Found| D[Return Cached Response Immediately]
D --> E[User Sees Content Instantly]
C -->|Not Found| F[Wait for Network]
D --> G[Fetch Fresh Version in Background]
C --> G
G --> H{Network Success?}
H -->|Yes| I[Update Cache Silently]
I --> J[Next Request Gets Fresh Version]
H -->|No| K[Keep Old Cache]
F --> L{Network Success?}
L -->|Yes| M[Cache & Return]
L -->|No| N[Error]
style D fill:#90EE90
style E fill:#90EE90
style I fill:#87CEEB
style J fill:#87CEEB
style M fill:#90EE90
References:
- Stale-While-Revalidate Strategy - web.dev
- HTTP Cache-Control: stale-while-revalidate
- Workbox Stale-While-Revalidate
Push Notifications
What is the Push API and Notification API?
The 30-Second Answer: The Push API enables servers to send messages to service workers even when the PWA isn't running, while the Notification API displays those messages to users. They're separate but complementary—Push receives the data, Notifications shows it to the user.
The 2-Minute Answer (If They Want More): The Push API and Notification API are two distinct web standards that work together to enable push messaging in PWAs, but each serves a different purpose.
The Push API handles the subscription and delivery mechanism. It allows your service worker to subscribe to a push service, creating a unique subscription object with an endpoint URL and encryption keys. When your server sends a message to this endpoint, the push service delivers it to the service worker via a 'push' event, even if the browser or PWA isn't actively running. The Push API is entirely about the delivery infrastructure and doesn't involve any user-facing UI.
The Notification API, on the other hand, is responsible for displaying alerts to the user. It handles permission requests, creates notification objects with titles, bodies, icons, and action buttons, and manages user interactions like clicks. The Notification API can be used independently of push notifications—you can show notifications from your main app thread for immediate alerts. However, for background notifications, it's typically called from within the service worker's push event handler.
Together, these APIs create the complete push notification experience: the Push API ensures reliable message delivery from server to service worker, and the Notification API provides the user interface for displaying those messages. The separation of concerns allows for flexibility—you can receive push events without showing notifications (for silent background sync) or show notifications without push events (for local alerts).
Code Example:
// Push API - Subscribing in your main app
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
// Public VAPID key from your server
const vapidPublicKey = 'BKxH8...your-public-key';
const convertedKey = urlBase64ToUint8Array(vapidPublicKey);
// Create a push subscription
const subscription = await registration.pushManager.subscribe({
userVisibleHint: true, // Must show notification to user
applicationServerKey: convertedKey
});
// Send subscription to your server
await fetch('/api/push-subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
return subscription;
}
// Notification API - Permission request
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('Notification permission granted');
return true;
} else if (permission === 'denied') {
console.log('Notification permission denied');
return false;
}
// 'default' means user dismissed without choosing
}
// In Service Worker - Receiving push and showing notification
self.addEventListener('push', event => {
let notificationData = { title: 'Default title' };
if (event.data) {
notificationData = event.data.json();
}
// Notification API - Show the notification
const promiseChain = self.registration.showNotification(
notificationData.title,
{
body: notificationData.body,
icon: '/images/icon-192x192.png',
badge: '/images/badge-72x72.png',
vibrate: [200, 100, 200],
tag: notificationData.tag || 'default-tag',
data: notificationData.url,
actions: [
{ action: 'view', title: 'View' },
{ action: 'dismiss', title: 'Dismiss' }
]
}
);
event.waitUntil(promiseChain);
});
// Utility function for VAPID key conversion
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
References:
↑ Back to topInstallation and App-like Experience
What is the beforeinstallprompt event?
The 30-Second Answer:
The beforeinstallprompt event is a browser event that fires when a PWA meets the installability criteria, giving developers the opportunity to customize when and how the installation prompt appears. It can be prevented, stored, and triggered later with a custom UI, allowing for better control over the user experience.
The 2-Minute Answer (If They Want More):
beforeinstallprompt is a crucial event in the PWA installation lifecycle that serves as the bridge between the browser's automatic installability detection and developer-controlled installation UX. When the browser determines that a web app meets all the installation requirements (HTTPS, valid manifest, service worker with fetch handler), it fires this event before displaying any default installation UI.
The event object provides two key capabilities: the preventDefault() method allows you to suppress the browser's default installation prompt (like Chrome's mini-infobar), and the prompt() method lets you trigger the native installation dialog at a time of your choosing. This is powerful because it transforms installation from an unpredictable browser behavior into a deliberate UX moment that you control.
The event also exposes a userChoice property, which is a promise that resolves with an object containing the user's decision. The outcome will be either 'accepted' if the user installed the app or 'dismissed' if they declined. This feedback is invaluable for analytics and for optimizing your installation strategy - you can track conversion rates, A/B test different prompts, or adjust your approach based on user behavior.
Important constraints to understand: beforeinstallprompt only fires once per session until the page is reloaded, and prompt() can only be called once per event instance. Additionally, prompt() must be invoked in response to a user gesture (like a click) - you can't programmatically trigger it on page load. The event won't fire at all if the PWA is already installed or if the user has previously dismissed the prompt (Chrome remembers this for about 3 months). Finally, this event is currently supported in Chromium-based browsers but not in Safari, which uses a different installation mechanism through the Share menu.
Code Example:
// Complete beforeinstallprompt event handling
class PWAInstaller {
constructor() {
this.deferredPrompt = null;
this.setupEventListeners();
}
setupEventListeners() {
// Listen for beforeinstallprompt
window.addEventListener('beforeinstallprompt', (event) => {
console.log('👍 beforeinstallprompt event was fired');
// Prevent the default mini-infobar or install dialog
event.preventDefault();
// Store the event for later use
this.deferredPrompt = event;
// Log event properties for debugging
console.log('Event platforms:', event.platforms); // e.g., ['web', 'android']
// Update UI to show install option
this.showInstallPromotion();
// Optional: Track that the prompt was available
this.trackEvent('install_prompt_available');
});
// Listen for successful installation
window.addEventListener('appinstalled', (event) => {
console.log('👍 PWA was installed successfully');
// Clear the deferred prompt
this.deferredPrompt = null;
// Hide install promotion
this.hideInstallPromotion();
// Track successful installation
this.trackEvent('pwa_installed', {
installation_source: 'custom_prompt'
});
});
}
async showInstallPrompt() {
if (!this.deferredPrompt) {
console.log('❌ No deferred prompt available');
return false;
}
try {
// Show the install prompt
this.deferredPrompt.prompt();
// Wait for the user to respond
const choiceResult = await this.deferredPrompt.userChoice;
console.log(`User choice: ${choiceResult.outcome}`);
// Track the user's choice
this.trackEvent('install_prompt_response', {
outcome: choiceResult.outcome,
platform: choiceResult.platform
});
if (choiceResult.outcome === 'accepted') {
console.log('âś… User accepted the install prompt');
} else {
console.log('❌ User dismissed the install prompt');
this.hideInstallPromotion();
}
// Clear the deferred prompt (can only be used once)
this.deferredPrompt = null;
return choiceResult.outcome === 'accepted';
} catch (error) {
console.error('Error showing install prompt:', error);
return false;
}
}
showInstallPromotion() {
const installBanner = document.getElementById('install-banner');
if (installBanner) {
installBanner.classList.add('show');
installBanner.setAttribute('aria-hidden', 'false');
}
}
hideInstallPromotion() {
const installBanner = document.getElementById('install-banner');
if (installBanner) {
installBanner.classList.remove('show');
installBanner.setAttribute('aria-hidden', 'true');
}
}
trackEvent(eventName, params = {}) {
// Send to your analytics platform
if (typeof gtag !== 'undefined') {
gtag('event', eventName, params);
}
console.log(`📊 Event tracked: ${eventName}`, params);
}
// Check if PWA can be installed
canInstall() {
return this.deferredPrompt !== null;
}
}
// Initialize the installer
const pwaInstaller = new PWAInstaller();
// Usage: Trigger install from custom button
document.getElementById('install-button')?.addEventListener('click', async () => {
const installed = await pwaInstaller.showInstallPrompt();
if (installed) {
console.log('Installation initiated');
}
});
// Example: Show install prompt after user completes an action
function onUserCompletedTask() {
// User just finished something valuable
console.log('User completed task');
// Check if we can prompt for installation
if (pwaInstaller.canInstall()) {
setTimeout(() => {
// Show contextual prompt after slight delay
pwaInstaller.showInstallPromotion();
}, 2000);
}
}
// Advanced: Timing and frequency control
class SmartPWAInstaller extends PWAInstaller {
constructor() {
super();
this.promptsShown = this.getPromptsShown();
this.maxPrompts = 3; // Don't be annoying
this.minSessionTime = 30000; // Wait 30 seconds
this.sessionStartTime = Date.now();
}
getPromptsShown() {
return parseInt(localStorage.getItem('pwa_prompts_shown') || '0');
}
incrementPromptsShown() {
this.promptsShown++;
localStorage.setItem('pwa_prompts_shown', this.promptsShown.toString());
}
shouldShowPrompt() {
// Don't show if we've shown too many times
if (this.promptsShown >= this.maxPrompts) {
console.log('Max prompts reached');
return false;
}
// Don't show if user just arrived
const sessionTime = Date.now() - this.sessionStartTime;
if (sessionTime < this.minSessionTime) {
console.log('Not enough session time');
return false;
}
// Don't show if no deferred prompt
if (!this.canInstall()) {
console.log('Cannot install');
return false;
}
return true;
}
async showInstallPrompt() {
if (!this.shouldShowPrompt()) {
return false;
}
this.incrementPromptsShown();
return await super.showInstallPrompt();
}
}
Mermaid Diagram:
flowchart TD
A[Page Loads] --> B{Meets Install Criteria?}
B -->|No| C[No Event Fired]
B -->|Yes| D{Already Installed?}
D -->|Yes| C
D -->|No| E{Previously Dismissed?}
E -->|Yes - within 3 months| C
E -->|No| F[Browser Fires beforeinstallprompt]
F --> G[Event Object Created]
G --> H{Developer Calls preventDefault?}
H -->|No| I[Browser Shows Default UI]
H -->|Yes| J[Default UI Suppressed]
J --> K[Store Event Object]
K --> L[Wait for User Gesture]
L --> M[User Clicks Install Button]
M --> N[Call deferredPrompt.prompt]
N --> O[Browser Shows Native Dialog]
O --> P{User Response}
P -->|Accept| Q[userChoice: 'accepted']
P -->|Dismiss| R[userChoice: 'dismissed']
Q --> S[appinstalled Event Fires]
R --> T[Clear Event Object]
S --> T
style F fill:#4CAF50
style N fill:#2196F3
style S fill:#FF9800
References:
- BeforeInstallPromptEvent - MDN Web Docs
- How to provide your own in-app install experience - web.dev
- The beforeinstallprompt event - web.dev
Performance and Best Practices
What is the App Shell architecture pattern?
The 30-Second Answer: The App Shell is a PWA design pattern where you separate your minimal HTML, CSS, and JavaScript needed to power the UI (the "shell") from dynamic content. The shell is cached aggressively by the service worker and loads instantly, then data is loaded dynamically. This creates a native-app-like experience with instant perceived loading.
The 2-Minute Answer (If They Want More): App Shell architecture treats your PWA like a native app by splitting it into two parts: the application shell (static UI framework) and dynamic content (data that changes). Think of it like a native app's compiled binary (the shell) versus the content it fetches from servers.
The shell includes your app's navigation bar, menu, layout structure, loading states, and core UI components—everything needed to render your interface except the actual data. Since this rarely changes, you precache it with your service worker during installation. When a user launches your app, the shell loads instantly from cache (even offline), giving immediate visual feedback.
Once the shell renders, you fetch dynamic content via API calls. This content might use different caching strategies—NetworkFirst for fresh data, or StaleWhileRevalidate for a balance. The user sees your app's interface instantly, then content populates within it, similar to how native apps work.
This pattern excels for single-page applications where the UI framework is consistent but content changes frequently. Twitter's PWA is a perfect example: the tweet composition UI, navigation, and layout are the shell; the tweets themselves are dynamic content. The shell loads in under a second, then tweets stream in.
The key benefit is perceived performance. Users see something meaningful instantly instead of a white screen, and the app works offline at least to show the UI and cached content. Combined with skeleton screens in your shell, users experience minimal frustration even on slow networks.
Code Example:
// service-worker.js - App Shell caching strategy
const CACHE_NAME = 'app-shell-v1';
const DATA_CACHE_NAME = 'app-data-v1';
// App Shell resources - cached during install
const APP_SHELL = [
'/',
'/index.html',
'/styles/app.css',
'/scripts/app.js',
'/scripts/router.js',
'/images/logo.svg',
'/fonts/main.woff2',
'/manifest.json'
];
// Install: precache the App Shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Caching App Shell');
return cache.addAll(APP_SHELL);
})
.then(() => self.skipWaiting())
);
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== DATA_CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
return self.clients.claim();
});
// Fetch: App Shell from cache, data from network
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API requests: Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(
caches.open(DATA_CACHE_NAME).then((cache) => {
return fetch(request)
.then((response) => {
// Cache successful responses
if (response.status === 200) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => {
// Fallback to cached data
return cache.match(request);
});
})
);
return;
}
// App Shell: Cache First
event.respondWith(
caches.match(request).then((response) => {
return response || fetch(request);
})
);
});
// app.js - App Shell rendering
class AppShell {
constructor() {
this.routes = {
'/': this.renderHome,
'/questions': this.renderQuestions,
'/profile': this.renderProfile
};
this.init();
}
init() {
// Render the persistent shell
this.renderShell();
// Set up routing
window.addEventListener('popstate', () => this.handleRoute());
document.addEventListener('click', (e) => {
if (e.target.matches('a[data-route]')) {
e.preventDefault();
this.navigate(e.target.getAttribute('href'));
}
});
// Load initial route
this.handleRoute();
}
renderShell() {
// Render the static shell that never changes
document.body.innerHTML = `
<div class="app">
<header class="app-header">
<img src="/images/logo.svg" alt="Logo" class="logo">
<nav class="nav">
<a href="/" data-route>Home</a>
<a href="/questions" data-route>Questions</a>
<a href="/profile" data-route>Profile</a>
</nav>
</header>
<main id="content" class="app-content">
<!-- Dynamic content loads here -->
<div class="skeleton-loader">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
</main>
<footer class="app-footer">
<p>© 2025 Interview Prep Pro</p>
</footer>
</div>
`;
}
async handleRoute() {
const path = window.location.pathname;
const handler = this.routes[path] || this.render404;
// Show skeleton while loading
this.showSkeleton();
try {
// Fetch and render dynamic content
await handler.call(this);
} catch (error) {
console.error('Route error:', error);
this.renderError();
}
}
showSkeleton() {
const content = document.getElementById('content');
content.innerHTML = `
<div class="skeleton-loader">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
`;
}
async renderQuestions() {
try {
// Fetch dynamic data
const response = await fetch('/api/questions');
const questions = await response.json();
// Render into shell
const content = document.getElementById('content');
content.innerHTML = `
<h1>Interview Questions</h1>
<ul class="questions-list">
${questions.map(q => `
<li class="question-card">
<h3>${q.title}</h3>
<p>${q.preview}</p>
</li>
`).join('')}
</ul>
`;
} catch (error) {
this.renderError();
}
}
navigate(path) {
window.history.pushState({}, '', path);
this.handleRoute();
}
renderError() {
const content = document.getElementById('content');
content.innerHTML = `
<div class="error-state">
<h2>Unable to load content</h2>
<p>Please check your connection and try again.</p>
</div>
`;
}
}
// Initialize app when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new AppShell());
} else {
new AppShell();
}
/* Skeleton screen styles for perceived performance */
.skeleton-loader {
padding: 20px;
}
.skeleton-line {
height: 20px;
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-bottom: 15px;
border-radius: 4px;
}
.skeleton-line:nth-child(1) { width: 80%; }
.skeleton-line:nth-child(2) { width: 100%; }
.skeleton-line:nth-child(3) { width: 60%; }
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Mermaid Diagram:
flowchart TD
A[User Opens PWA] --> B[Service Worker]
B --> C{App Shell Cached?}
C -->|Yes| D[Load Shell from Cache]
C -->|No| E[Fetch Shell from Network]
E --> F[Cache Shell]
F --> D
D --> G[Render App Shell<br/>Header, Nav, Footer]
G --> H[Show Skeleton Loader]
H --> I[Fetch Dynamic Content]
I --> J{Network Available?}
J -->|Yes| K[Fetch from API]
J -->|No| L[Load from Cache]
K --> M[Update Cache]
M --> N[Render Content]
L --> N
N --> O[Complete App Experience]
style D fill:#90EE90
style G fill:#87CEEB
style N fill:#FFD700
References:
↑ Back to top