Progressive Web Apps have become essential for reaching users in markets with unpredictable connectivity. Whether you’re building for the UAE, Southeast Asia, or anywhere with variable network conditions, the combination of Service Workers and edge caching creates a resilient application layer that works seamlessly online and offline.
This guide walks you through building production-ready PWAs with an offline-first mindset, covering architecture patterns, caching strategies, and deployment considerations that matter in the real world.
Why Offline-First Matters
Traditional web applications assume a persistent connection. The moment connectivity drops, users see blank screens or timeouts. Offline-first architecture flips this assumption: the app works locally by default and syncs with the server when possible.
For mobile users in regions with spotty coverage, this isn’t a nice-to-have, it’s essential. A user on the Dubai Metro, moving between areas with weak signal, shouldn’t lose their ability to interact with your app. Building with this constraint in mind creates better experiences everywhere.
The technical foundation rests on three pillars: Service Workers for client-side caching and background sync, edge caching for faster content delivery, and a cache-first or stale-while-revalidate strategy that prioritizes local data.
Understanding Service Workers
A Service Worker is a JavaScript worker that runs separately from your main application thread. It acts as a proxy between your web app and the network, intercepting requests and deciding whether to serve cached content, fetch fresh data, or handle offline scenarios.
Service Workers are powerful because they persist across browser sessions, can handle background tasks, and give you fine-grained control over what gets cached and when. According to web.dev, a Service Worker’s lifecycle starts with registration, followed by installation and activation before it can control your app’s pages.
Registering a Service Worker
Start by registering your Service Worker in your main application file:
// main.ts
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then((registration) => {
console.log('Service Worker registered:', registration);
}).catch((error) => {
console.error('Service Worker registration failed:', error);
});
}
The registration happens asynchronously. The browser downloads the Service Worker script, installs it, and activates it in the background. Users won’t see any change, but your app is now ready to intercept requests.
Building Your Service Worker
Your Service Worker file (sw.js) is where the magic happens. It listens for events like install, activate, and fetch:
// sw.ts (compiled to sw.js)
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/manifest.json'
];
// Install event: cache static assets
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
});
// Activate event: clean up old caches
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});
// Fetch event: intercept requests
self.addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});
This basic Service Worker implements a cache-first strategy: it checks the cache first, and only fetches from the network if the resource isn’t cached. For static assets like CSS and JavaScript, this is ideal.
Caching Strategies
Not every request should be cached the same way. Different strategies suit different content types. Web.dev’s offline cookbook outlines several proven patterns.
Cache-First (Cache, Falling Back to Network)
Best for: Static assets, images, fonts, app shell.
This strategy prioritizes the cache. If a resource is cached, it’s served instantly. Only if it’s not cached does the app fetch from the network. This maximizes speed and works offline, but cached content won’t update until the cache is invalidated.
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.destination === 'image') {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request).then((response) => {
// Cache new images for future use
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, response.clone());
});
return response;
});
})
);
}
});
Stale-While-Revalidate
Best for: API responses, dynamic content, user data.
This strategy serves cached content immediately, then fetches fresh data in the background. On the next request, the user sees the updated content. It’s a great middle ground: users get instant responses and eventually see fresh data.
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
caches.open('api-cache').then((cache) => {
return cache.match(event.request).then((response) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
});
})
);
}
});
Network-First (Network, Falling Back to Cache)
Best for: Critical API calls, real-time data, authentication.
This strategy tries the network first and falls back to the cache if the network fails. It ensures users see the freshest data when possible, but still works offline.
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.url.includes('/api/user')) {
event.respondWith(
fetch(event.request)
.then((response) => {
caches.open('api-cache').then((cache) => {
cache.put(event.request, response.clone());
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
}
});
Edge Caching for Global Performance
Service Workers handle client-side caching, but edge caching works on the server side. A CDN like Azure Front Door, Cloudflare, or AWS CloudFront caches your content at edge locations near your users, dramatically reducing latency and server load.
For PWAs serving users across the UAE and beyond, edge caching is essential. A user in Abu Dhabi shouldn’t wait for content to travel from a server in Europe.
Setting Cache Headers
Control edge caching with HTTP headers. Your backend should set appropriate Cache-Control headers:
// Express.js example
app.get('/api/data', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
res.json({ data: 'your data' });
});
app.get('/api/user', (req, res) => {
res.set('Cache-Control', 'private, max-age=300'); // Cache for 5 minutes, user-specific
res.json({ user: 'data' });
});
app.get('/static/image.jpg', (req, res) => {
res.set('Cache-Control', 'public, max-age=31536000, immutable'); // Cache for 1 year
res.sendFile('image.jpg');
});
Use immutable for versioned assets (files with hashes in their names). Use shorter TTLs for dynamic content. Private responses (like user data) should only be cached on the client, not on shared CDN caches.
Azure Front Door Configuration
If you’re deploying on Azure, Front Door provides edge caching with minimal configuration:
{
"frontendEndpoints": [
{
"name": "myapp",
"hostName": "myapp.azurefd.net"
}
],
"backendPools": [
{
"name": "myBackend",
"backends": [
{
"address": "myapp.azurewebsites.net",
"httpPort": 80,
"httpsPort": 443
}
]
}
],
"routingRules": [
{
"name": "cacheRule",
"frontendEndpoints": ["myapp"],
"acceptedProtocols": ["https"],
"patternsToMatch": ["/api/*"],
"routeConfiguration": {
"forwardingProtocol": "HttpsOnly",
"cacheConfiguration": {
"cacheDuration": "00:05:00",
"dynamicCompression": "Enabled"
}
}
}
]
}
Offline-First Architecture Pattern
Combining Service Workers and edge caching requires a thoughtful architecture. Here’s a production pattern:
App Shell Architecture
Cache the minimal HTML, CSS, and JavaScript needed for the app to render. This is your app shell. Once the shell loads, fetch dynamic content separately.
// Service Worker: cache app shell
const APP_SHELL = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js'
];
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open('app-shell-v1').then((cache) => {
return cache.addAll(APP_SHELL);
})
);
});
self.addEventListener('fetch', (event: FetchEvent) => {
const { request } = event;
const url = new URL(request.url);
// App shell: cache-first
if (APP_SHELL.some((asset) => url.pathname === asset)) {
event.respondWith(
caches.match(request).then((response) => {
return response || fetch(request);
})
);
}
// API: stale-while-revalidate
else if (url.pathname.startsWith('/api/')) {
event.respondWith(
caches.open('api-cache').then((cache) => {
return cache.match(request).then((response) => {
const fetchPromise = fetch(request).then((networkResponse) => {
cache.put(request, networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
});
})
);
}
// Images: cache-first with update
else if (request.destination === 'image') {
event.respondWith(
caches.match(request).then((response) => {
return response || fetch(request).then((networkResponse) => {
caches.open('images').then((cache) => {
cache.put(request, networkResponse.clone());
});
return networkResponse;
});
})
);
}
// Default: network-first
else {
event.respondWith(
fetch(request).catch(() => {
return caches.match(request);
})
);
}
});
Background Sync for Offline Actions
Let users perform actions offline (like submitting forms), and sync them to the server when connectivity returns. The Background Sync API makes this possible:
// Main app: queue action for sync
async function submitFormOffline(data: any) {
// Store in IndexedDB
const db = await openDB('app-db');
await db.add('pending-actions', {
action: 'submitForm',
data,
timestamp: Date.now()
});
// Register sync
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register('sync-pending-actions');
}
}
// Service Worker: handle sync
self.addEventListener('sync', (event: any) => {
if (event.tag === 'sync-pending-actions') {
event.waitUntil(
(async () => {
const db = await openDB('app-db');
const actions = await db.getAll('pending-actions');
for (const action of actions) {
try {
await fetch('/api/actions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.data)
});
await db.delete('pending-actions', action.id);
} catch (error) {
console.error('Sync failed:', error);
throw error; // Retry
}
}
})()
);
}
});
Monitoring and Performance Metrics
Build visibility into your PWA’s performance. Track key metrics that matter for offline-first apps:
- Time to First Byte (TTFB): How long until the first content appears.
- Cache Hit Ratio: Percentage of requests served from cache.
- Offline Usage: How often users interact with your app offline.
- Sync Success Rate: Percentage of background syncs that succeed.
// Track cache hits
self.addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
// Cache hit
navigator.sendBeacon('/metrics', JSON.stringify({
event: 'cache-hit',
url: event.request.url,
timestamp: Date.now()
}));
return response;
}
return fetch(event.request);
})
);
});
// Track offline usage
window.addEventListener('offline', () => {
navigator.sendBeacon('/metrics', JSON.stringify({
event: 'offline',
timestamp: Date.now()
}));
});
Deployment Considerations
Deploying a PWA requires attention to a few details.
HTTPS is Mandatory
Service Workers only work over HTTPS (except localhost for development). This is a security requirement, not a limitation. Ensure your domain has a valid SSL certificate.
Web App Manifest
Create a manifest.json file to define your PWA’s metadata:
{
"name": "My Awesome App",
"short_name": "MyApp",
"description": "An offline-first progressive web app",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#007bff",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Link it in your HTML:
<link rel="manifest" href="/manifest.json">
Service Worker Updates
When you update your Service Worker, the browser automatically detects the change and installs the new version. However, the old Service Worker remains active until all tabs using it are closed. To force activation or notify users of updates:
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('New Service Worker is now active');
// Optionally reload the page or show an update notification
});
navigator.serviceWorker.oncontroller = () => {
window.location.reload();
};
Real-World Optimization for Variable Connectivity
For users in regions like the UAE with variable connectivity, a few extra practices help:
- Implement request timeouts so users don’t hang waiting for slow networks.
- Compress assets aggressively; every kilobyte counts on slow connections.
- Lazy-load images and non-critical resources.
- Use WebP or modern image formats with fallbacks.
- Monitor network conditions and adapt content quality accordingly.
// Detect network quality and adapt
const connection = (navigator as any).connection;
if (connection) {
const effectiveType = connection.effectiveType; // 4g, 3g, 2g, slow-2g
const saveData = connection.saveData; // User enabled data saver
if (effectiveType === '2g' || effectiveType === 'slow-2g' || saveData) {
// Load lower-quality images, disable auto-play, etc.
document.body.classList.add('low-bandwidth');
}
connection.addEventListener('change', () => {
// Re-evaluate as connection changes
});
}
Wrapping Up
Building offline-first PWAs with Service Workers and edge caching is no longer experimental, it’s essential for modern mobile applications. The combination of client-side caching, background sync, and edge delivery creates applications that work reliably regardless of network conditions.
Start with the app shell pattern, implement stale-while-revalidate for dynamic content, and layer edge caching for global performance. Test thoroughly on slow networks and real devices. Monitor your metrics and iterate based on user behavior.
The investment in offline-first architecture pays dividends in user retention, engagement, and satisfaction, especially in markets where connectivity is unpredictable. Your users will notice the difference immediately.
Do I need to use a specific framework to build PWAs?
No. PWAs are built with standard web technologies: HTML, CSS, JavaScript, and Service Workers. You can use any framework (React, Vue, Angular, Svelte) or none at all. The key is implementing Service Workers and a web app manifest correctly.
How large can my Service Worker cache be?
Cache size limits depend on the browser and device. Most modern browsers allow 50MB or more per origin, but it varies. Monitor your cache size and implement cleanup strategies. Remove old cache versions in the activate event to free up space.
Will my PWA work offline completely?
Only if you cache the content. A PWA without cached data can’t work offline. Plan which content is essential for offline use and cache it strategically. Use stale-while-revalidate for frequently accessed data so users always have something to see.
How do I test offline functionality?
Use browser DevTools. In Chrome, open DevTools, go to Application > Service Workers, and check ‘Offline’. Or use the Network tab to set throttling. For realistic testing, use tools like Puppeteer or test on real devices with poor connectivity.
Can I update my Service Worker without users noticing?
Users won’t see updates until they close and reopen the app or reload the page. To push updates immediately, notify users that a new version is available and ask them to refresh. The new Service Worker activates after the page reloads.