Progressive Web Apps in 2025: Indistinguishable from Native
PWAs have evolved significantly with new APIs and capabilities that blur the line between web and native apps. Discover what's possible today.
Progressive Web Apps in 2025: Indistinguishable from Native
Progressive Web Apps (PWAs) continue to evolve, gaining capabilities that once required native apps. With improved browser support and powerful new APIs, PWAs are becoming a compelling alternative to traditional native development.
What Makes a PWA in 2024?
Modern PWAs combine:
- Installability: Add to home screen
- Offline functionality: Service Workers
- Native capabilities: Advanced APIs
- App-like experience: Full-screen, smooth transitions
- Progressive enhancement: Works everywhere, enhanced where supported
Core Technologies
Service Workers
The foundation of PWAs:
// sw.js
const CACHE_NAME = 'v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/script/main.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => response || fetch(event.request))
);
});
Web App Manifest
Define your app's appearance:
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A modern PWA",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#007bff",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "540x720",
"type": "image/png"
}
],
"categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "New Document",
"url": "/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
}
]
}
Modern PWA Capabilities
1. File System Access
Read and write files like a native app:
// Open file picker
const [fileHandle] = await window.showOpenFilePicker();
const file = await fileHandle.getFile();
const contents = await file.text();
// Save file
const saveHandle = await window.showSaveFilePicker({
suggestedName: 'document.txt',
types: [{
description: 'Text Files',
accept: { 'text/plain': ['.txt'] },
}],
});
const writable = await saveHandle.createWritable();
await writable.write(contents);
await writable.close();
2. Web Share API
Native sharing:
if (navigator.share) {
await navigator.share({
title: 'Check out this article',
text: 'Amazing PWA features',
url: 'https://example.com/article',
});
}
// Share files
await navigator.share({
files: [file],
title: 'My Photo',
});
3. Web Push Notifications
Engage users like native apps:
// Request permission
const permission = await Notification.requestPermission();
if (permission === 'granted') {
// Subscribe to push
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});
// Send subscription to server
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' },
});
}
// In service worker
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
actions: [
{ action: 'open', title: 'Open App' },
{ action: 'close', title: 'Dismiss' },
],
})
);
});
4. Background Sync
Retry failed requests:
// Register sync
await registration.sync.register('send-message');
// In service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'send-message') {
event.waitUntil(sendPendingMessages());
}
});
async function sendPendingMessages() {
const messages = await getMessagesFromIndexedDB();
for (const message of messages) {
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message),
});
await deleteMessageFromIndexedDB(message.id);
} catch (error) {
// Will retry on next sync
}
}
}
5. Periodic Background Sync
Update content in the background:
// Request permission
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
if (status.state === 'granted') {
// Register periodic sync (minimum 12 hours)
await registration.periodicSync.register('update-content', {
minInterval: 24 * 60 * 60 * 1000, // 24 hours
});
}
// In service worker
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'update-content') {
event.waitUntil(updateContent());
}
});
6. Badge API
Update app icon badge:
// Set badge number
navigator.setAppBadge(5);
// Clear badge
navigator.clearAppBadge();
7. Install Prompts
Control when to prompt installation:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent default prompt
e.preventDefault();
deferredPrompt = e;
// Show custom install button
showInstallButton();
});
async function installApp() {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('App installed');
}
deferredPrompt = null;
}
}
8. Web Bluetooth
Connect to Bluetooth devices:
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }],
});
const server = await device.gatt.connect();
const service = await server.getPrimaryService('heart_rate');
const characteristic = await service.getCharacteristic('heart_rate_measurement');
characteristic.addEventListener('characteristicvaluechanged', (event) => {
const heartRate = event.target.value.getUint8(1);
console.log('Heart rate:', heartRate);
});
await characteristic.startNotifications();
9. Geolocation
Access user location:
if ('geolocation' in navigator) {
// One-time position
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
console.log(latitude, longitude);
}
);
// Watch position
const watchId = navigator.geolocation.watchPosition(
(position) => {
updateMap(position.coords);
}
);
}
10. Media Devices
Access camera and microphone:
// Camera
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false,
});
videoElement.srcObject = stream;
// Screen sharing
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
});
Advanced Caching Strategies
1. Cache First
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((cached) => cached || fetch(event.request))
);
});
2. Network First
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.catch(() => caches.match(event.request))
);
});
3. Stale While Revalidate
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
});
4. Cache Strategies with Workbox
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// Images: Cache first
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
})
);
// API: Network first
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
}),
],
})
);
// Pages: Stale while revalidate
registerRoute(
({ request }) => request.destination === 'document',
new StaleWhileRevalidate({
cacheName: 'pages',
})
);
Offline-First Architecture
IndexedDB for Storage
// Open database
const db = await new Promise((resolve, reject) => {
const request = indexedDB.open('MyApp', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore('posts', { keyPath: 'id' });
};
});
// Add data
const transaction = db.transaction(['posts'], 'readwrite');
const store = transaction.objectStore('posts');
store.add({ id: 1, title: 'My Post', content: '...' });
// Query data
const getTransaction = db.transaction(['posts'], 'readonly');
const getStore = getTransaction.objectStore('posts');
const post = await getStore.get(1);
Sync Queue Pattern
class SyncQueue {
constructor() {
this.queue = [];
this.processing = false;
}
async add(request) {
this.queue.push(request);
await this.save();
if (!this.processing) {
this.process();
}
}
async process() {
this.processing = true;
while (this.queue.length > 0) {
const request = this.queue[0];
try {
await fetch(request.url, request.options);
this.queue.shift();
await this.save();
} catch (error) {
// Still offline, stop processing
break;
}
}
this.processing = false;
}
async save() {
localStorage.setItem('syncQueue', JSON.stringify(this.queue));
}
}
Performance Optimization
1. Lazy Load Routes
const routes = {
'/': () => import('./pages/home.js'),
'/about': () => import('./pages/about.js'),
'/products': () => import('./pages/products.js'),
};
async function navigate(path) {
const pageModule = await routes[path]();
pageModule.render();
}
2. Prefetch Resources
// Prefetch next likely page
const links = document.querySelectorAll('a');
links.forEach((link) => {
link.addEventListener('mouseenter', () => {
const href = link.getAttribute('href');
prefetchPage(href);
});
});
function prefetchPage(url) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
}
3. App Shell Pattern
// Cache app shell during install
const SHELL_CACHE = 'shell-v1';
const SHELL_FILES = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.svg',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE)
.then((cache) => cache.addAll(SHELL_FILES))
);
});
Testing PWAs
1. Lighthouse
# CLI
npm install -g lighthouse
lighthouse https://example.com --view
# Programmatic
import lighthouse from 'lighthouse';
const result = await lighthouse('https://example.com');
2. PWA Builder
Test and package your PWA:
- Visit PWABuilder.com
- Enter your URL
- Get PWA score and recommendations
- Generate app packages for stores
3. Workbox Testing
import { precacheAndRoute } from 'workbox-precaching';
// Test service worker
describe('Service Worker', () => {
it('should cache shell files', async () => {
const cache = await caches.open('shell-v1');
const keys = await cache.keys();
expect(keys.length).toBeGreaterThan(0);
});
});
Real-World Success Stories
Twitter Lite
- 65% increase in pages per session
- 75% increase in Tweets sent
- 20% decrease in bounce rate
Tinder
- 90% reduction in load time
- Swiping in seconds instead of minutes
- 10x less data usage
Starbucks
- 2x daily active users
- Works offline for browsing menu
- Smaller than iOS/Android apps
Distribution
1. App Stores
PWAs can be listed in:
- Microsoft Store
- Google Play Store (via Trusted Web Activity)
- Samsung Galaxy Store
2. Direct Installation
Users can install directly from browser:
- Chrome: Install button in address bar
- Safari: Share → Add to Home Screen
- Edge: App available button
When to Choose PWA
Great for:
- Content-heavy apps
- E-commerce
- News and media
- Social networks
- Productivity tools
Consider native when:
- Need cutting-edge device features
- Complex background processing
- High-performance games
- Apps requiring deep OS integration
Conclusion
PWAs in 2024 are more capable than ever. With powerful APIs, better browser support, and proven success stories, they're a compelling alternative to native development.
The line between web and native continues to blur. Are you building the next great PWA?
Jordan Patel
Web Developer & Technology Enthusiast