Back to Journal
Mobile/Frontend

Complete Guide to Progressive Web Apps with Typescript

A comprehensive guide to implementing Progressive Web Apps using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 18 min read

Progressive Web Apps combine the reach of the web with the capabilities traditionally reserved for native applications. TypeScript adds type safety to the complex APIs involved — service workers, cache management, push notifications, and background sync. This guide covers implementing a production-ready PWA in TypeScript from scratch.

Project Structure

Organize your PWA TypeScript project with clear separation between the main application and service worker code:

1src/
2├── app/
3 ├── main.ts # Application entry point
4 ├── pwa-register.ts # Service worker registration
5 └── components/
6├── sw/
7 ├── sw.ts # Service worker entry
8 ├── cache-strategies.ts # Caching logic
9 ├── sync-manager.ts # Background sync
10 └── push-handler.ts # Push notifications
11├── shared/
12 ├── types.ts # Shared types between app and SW
13 └── constants.ts # Cache names, API URLs
14└── tsconfig.sw.json # Separate TS config for SW
15 

The service worker runs in a separate context from the main application. Use a dedicated tsconfig.sw.json that targets the WebWorker lib:

json
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "ESNext",
5 "lib": ["ES2022", "WebWorker"],
6 "outDir": "./dist/sw",
7 "strict": true,
8 "noEmit": false,
9 "isolatedModules": true
10 },
11 "include": ["src/sw/**/*.ts", "src/shared/**/*.ts"]
12}
13 

Type-Safe Service Worker Registration

typescript
1// src/app/pwa-register.ts
2 
3interface ServiceWorkerConfig {
4 scope: string;
5 updateInterval: number;
6 onUpdateFound?: (registration: ServiceWorkerRegistration) => void;
7 onControllerChange?: () => void;
8}
9 
10export async function registerServiceWorker(config: ServiceWorkerConfig): Promise<ServiceWorkerRegistration | null> {
11 if (!('serviceWorker' in navigator)) {
12 console.warn('Service workers not supported');
13 return null;
14 }
15 
16 try {
17 const registration = await navigator.serviceWorker.register('/sw.js', {
18 scope: config.scope,
19 });
20 
21 // Check for updates periodically
22 setInterval(() => {
23 registration.update();
24 }, config.updateInterval);
25 
26 registration.addEventListener('updatefound', () => {
27 const newWorker = registration.installing;
28 if (!newWorker) return;
29 
30 newWorker.addEventListener('statechange', () => {
31 if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
32 config.onUpdateFound?.(registration);
33 }
34 });
35 });
36 
37 navigator.serviceWorker.addEventListener('controllerchange', () => {
38 config.onControllerChange?.();
39 });
40 
41 return registration;
42 } catch (error) {
43 console.error('SW registration failed:', error);
44 return null;
45 }
46}
47 
48// Usage
49registerServiceWorker({
50 scope: '/',
51 updateInterval: 60 * 60 * 1000, // Check hourly
52 onUpdateFound: (reg) => {
53 showUpdateBanner(() => {
54 reg.waiting?.postMessage({ type: 'SKIP_WAITING' });
55 });
56 },
57 onControllerChange: () => {
58 window.location.reload();
59 },
60});
61 

Service Worker with Typed Event Handling

typescript
1// src/sw/sw.ts
2/// <reference lib="webworker" />
3 
4declare const self: ServiceWorkerGlobalScope;
5 
6import { CacheManager } from './cache-strategies';
7import { SyncManager } from './sync-manager';
8import { PushHandler } from './push-handler';
9import { CACHE_VERSION, PRECACHE_URLS } from '../shared/constants';
10 
11const cacheManager = new CacheManager(CACHE_VERSION);
12const syncManager = new SyncManager();
13const pushHandler = new PushHandler();
14 
15// Install: precache critical assets
16self.addEventListener('install', (event) => {
17 event.waitUntil(
18 cacheManager.precache(PRECACHE_URLS).then(() => self.skipWaiting())
19 );
20});
21 
22// Activate: clean old caches
23self.addEventListener('activate', (event) => {
24 event.waitUntil(
25 cacheManager.cleanOldCaches().then(() => self.clients.claim())
26 );
27});
28 
29// Fetch: route to appropriate strategy
30self.addEventListener('fetch', (event) => {
31 const url = new URL(event.request.url);
32 
33 if (url.origin !== self.location.origin) return;
34 
35 if (event.request.mode === 'navigate') {
36 event.respondWith(cacheManager.navigationHandler(event.request));
37 return;
38 }
39 
40 if (url.pathname.startsWith('/api/')) {
41 event.respondWith(cacheManager.apiHandler(event.request));
42 return;
43 }
44 
45 if (url.pathname.match(/\.(js|css|png|jpg|svg|woff2)$/)) {
46 event.respondWith(cacheManager.staticAssetHandler(event.request));
47 return;
48 }
49});
50 
51// Background sync
52self.addEventListener('sync', (event) => {
53 if (event.tag.startsWith('sync-')) {
54 event.waitUntil(syncManager.process(event.tag));
55 }
56});
57 
58// Push notifications
59self.addEventListener('push', (event) => {
60 event.waitUntil(pushHandler.handlePush(event));
61});
62 
63self.addEventListener('notificationclick', (event) => {
64 event.waitUntil(pushHandler.handleClick(event));
65});
66 
67// Message handling for skip-waiting
68self.addEventListener('message', (event) => {
69 if (event.data?.type === 'SKIP_WAITING') {
70 self.skipWaiting();
71 }
72});
73 

Type-Safe Cache Strategies

typescript
1// src/sw/cache-strategies.ts
2 
3interface CacheConfig {
4 cacheName: string;
5 maxEntries?: number;
6 maxAge?: number;
7 networkTimeout?: number;
8}
9 
10export class CacheManager {
11 private version: string;
12 
13 constructor(version: string) {
14 this.version = version;
15 }
16 
17 async precache(urls: string[]): Promise<void> {
18 const cache = await caches.open(`precache-${this.version}`);
19 await cache.addAll(urls);
20 }
21 
22 async cleanOldCaches(): Promise<void> {
23 const keys = await caches.keys();
24 await Promise.all(
25 keys
26 .filter(key => !key.endsWith(this.version))
27 .map(key => caches.delete(key))
28 );
29 }
30 
31 async navigationHandler(request: Request): Promise<Response> {
32 try {
33 const response = await fetch(request);
34 return response;
35 } catch {
36 const cached = await caches.match('/index.html');
37 if (cached) return cached;
38 return new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/html' } });
39 }
40 }
41 
42 async apiHandler(request: Request): Promise<Response> {
43 const config: CacheConfig = {
44 cacheName: `api-${this.version}`,
45 maxAge: 5 * 60 * 1000,
46 networkTimeout: 3000,
47 };
48 
49 return this.networkFirst(request, config);
50 }
51 
52 async staticAssetHandler(request: Request): Promise<Response> {
53 const config: CacheConfig = {
54 cacheName: `static-${this.version}`,
55 maxEntries: 100,
56 };
57 
58 return this.cacheFirst(request, config);
59 }
60 
61 private async cacheFirst(request: Request, config: CacheConfig): Promise<Response> {
62 const cached = await caches.match(request);
63 if (cached) return cached;
64 
65 const response = await fetch(request);
66 if (response.ok) {
67 const cache = await caches.open(config.cacheName);
68 cache.put(request, response.clone());
69 if (config.maxEntries) {
70 await this.enforceCacheLimit(config.cacheName, config.maxEntries);
71 }
72 }
73 return response;
74 }
75 
76 private async networkFirst(request: Request, config: CacheConfig): Promise<Response> {
77 try {
78 const controller = new AbortController();
79 const timeoutId = config.networkTimeout
80 ? setTimeout(() => controller.abort(), config.networkTimeout)
81 : null;
82 
83 const response = await fetch(request, { signal: controller.signal });
84 if (timeoutId) clearTimeout(timeoutId);
85 
86 if (response.ok) {
87 const cache = await caches.open(config.cacheName);
88 cache.put(request, response.clone());
89 }
90 return response;
91 } catch {
92 const cached = await caches.match(request);
93 if (cached) {
94 if (config.maxAge) {
95 const cachedDate = cached.headers.get('date');
96 if (cachedDate && Date.now() - new Date(cachedDate).getTime() > config.maxAge) {
97 return new Response(JSON.stringify({ error: 'Stale cache' }), {
98 status: 503,
99 headers: { 'Content-Type': 'application/json' },
100 });
101 }
102 }
103 return cached;
104 }
105 return new Response(JSON.stringify({ error: 'Offline' }), {
106 status: 503,
107 headers: { 'Content-Type': 'application/json' },
108 });
109 }
110 }
111 
112 private async enforceCacheLimit(cacheName: string, maxEntries: number): Promise<void> {
113 const cache = await caches.open(cacheName);
114 const keys = await cache.keys();
115 if (keys.length > maxEntries) {
116 const toDelete = keys.slice(0, keys.length - maxEntries);
117 await Promise.all(toDelete.map(key => cache.delete(key)));
118 }
119 }
120}
121 

Background Sync with Type Safety

typescript
1// src/sw/sync-manager.ts
2 
3interface SyncItem {
4 id: string;
5 url: string;
6 method: string;
7 body: string;
8 headers: Record<string, string>;
9 timestamp: number;
10 retryCount: number;
11}
12 
13export class SyncManager {
14 private readonly DB_NAME = 'pwa-sync';
15 private readonly STORE_NAME = 'queue';
16 private readonly MAX_RETRIES = 5;
17 
18 async addToQueue(item: Omit<SyncItem, 'id' | 'retryCount'>): Promise<void> {
19 const db = await this.openDB();
20 const tx = db.transaction(this.STORE_NAME, 'readwrite');
21 tx.objectStore(this.STORE_NAME).add({
22 ...item,
23 id: crypto.randomUUID(),
24 retryCount: 0,
25 });
26 }
27 
28 async process(tag: string): Promise<void> {
29 const db = await this.openDB();
30 const tx = db.transaction(this.STORE_NAME, 'readonly');
31 const store = tx.objectStore(this.STORE_NAME);
32 const items: SyncItem[] = await this.getAllItems(store);
33 
34 const sorted = items.sort((a, b) => a.timestamp - b.timestamp);
35 
36 for (const item of sorted) {
37 if (item.retryCount >= this.MAX_RETRIES) {
38 await this.removeItem(db, item.id);
39 await this.notifyClient({
40 type: 'SYNC_FAILED',
41 itemId: item.id,
42 url: item.url,
43 });
44 continue;
45 }
46 
47 try {
48 const response = await fetch(item.url, {
49 method: item.method,
50 headers: item.headers,
51 body: item.body,
52 });
53 
54 if (response.ok) {
55 await this.removeItem(db, item.id);
56 await this.notifyClient({
57 type: 'SYNC_SUCCESS',
58 itemId: item.id,
59 url: item.url,
60 });
61 } else if (response.status >= 500) {
62 await this.incrementRetry(db, item.id);
63 } else {
64 // Client error (4xx) — don't retry
65 await this.removeItem(db, item.id);
66 await this.notifyClient({
67 type: 'SYNC_ERROR',
68 itemId: item.id,
69 status: response.status,
70 });
71 }
72 } catch {
73 // Network error — increment retry
74 await this.incrementRetry(db, item.id);
75 break; // Stop processing, still offline
76 }
77 }
78 }
79 
80 private async notifyClient(message: Record<string, unknown>): Promise<void> {
81 const clients = await self.clients.matchAll();
82 clients.forEach(client => client.postMessage(message));
83 }
84 
85 private openDB(): Promise<IDBDatabase> {
86 return new Promise((resolve, reject) => {
87 const request = indexedDB.open(this.DB_NAME, 1);
88 request.onupgradeneeded = () => {
89 const db = request.result;
90 if (!db.objectStoreNames.contains(this.STORE_NAME)) {
91 db.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
92 }
93 };
94 request.onsuccess = () => resolve(request.result);
95 request.onerror = () => reject(request.error);
96 });
97 }
98 
99 private getAllItems(store: IDBObjectStore): Promise<SyncItem[]> {
100 return new Promise((resolve, reject) => {
101 const req = store.getAll();
102 req.onsuccess = () => resolve(req.result);
103 req.onerror = () => reject(req.error);
104 });
105 }
106 
107 private async removeItem(db: IDBDatabase, id: string): Promise<void> {
108 const tx = db.transaction(this.STORE_NAME, 'readwrite');
109 tx.objectStore(this.STORE_NAME).delete(id);
110 }
111 
112 private async incrementRetry(db: IDBDatabase, id: string): Promise<void> {
113 const tx = db.transaction(this.STORE_NAME, 'readwrite');
114 const store = tx.objectStore(this.STORE_NAME);
115 const req = store.get(id);
116 req.onsuccess = () => {
117 const item = req.result;
118 if (item) {
119 item.retryCount++;
120 store.put(item);
121 }
122 };
123 }
124}
125 

Need a second opinion on your mobile/frontend architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Push Notification Handler

typescript
1// src/sw/push-handler.ts
2 
3interface PushPayload {
4 title: string;
5 body: string;
6 icon?: string;
7 badge?: string;
8 url?: string;
9 tag?: string;
10 actions?: Array<{ action: string; title: string; icon?: string }>;
11}
12 
13export class PushHandler {
14 async handlePush(event: PushEvent): Promise<void> {
15 const payload: PushPayload = event.data
16 ? event.data.json()
17 : { title: 'New notification', body: 'You have a new update' };
18 
19 await self.registration.showNotification(payload.title, {
20 body: payload.body,
21 icon: payload.icon || '/icons/192.png',
22 badge: payload.badge || '/icons/badge-72.png',
23 tag: payload.tag,
24 data: { url: payload.url || '/' },
25 actions: payload.actions || [],
26 });
27 }
28 
29 async handleClick(event: NotificationEvent): Promise<void> {
30 event.notification.close();
31 
32 const targetUrl = event.notification.data?.url || '/';
33 const action = event.action;
34 
35 if (action) {
36 await this.handleAction(action, targetUrl);
37 return;
38 }
39 
40 const windowClients = await self.clients.matchAll({
41 type: 'window',
42 includeUncontrolled: true,
43 });
44 
45 const existingClient = windowClients.find(
46 client => new URL(client.url).pathname === targetUrl
47 );
48 
49 if (existingClient) {
50 await existingClient.focus();
51 } else {
52 await self.clients.openWindow(targetUrl);
53 }
54 }
55 
56 private async handleAction(action: string, url: string): Promise<void> {
57 switch (action) {
58 case 'view':
59 await self.clients.openWindow(url);
60 break;
61 case 'dismiss':
62 break;
63 default:
64 await self.clients.openWindow(url);
65 }
66 }
67}
68 

Web App Manifest with TypeScript Validation

Create a build-time validator for your manifest:

typescript
1// scripts/validate-manifest.ts
2import { readFileSync } from 'node:fs';
3 
4interface ManifestIcon {
5 src: string;
6 sizes: string;
7 type: string;
8 purpose?: 'any' | 'maskable' | 'monochrome';
9}
10 
11interface WebAppManifest {
12 name: string;
13 short_name: string;
14 start_url: string;
15 display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
16 background_color: string;
17 theme_color: string;
18 icons: ManifestIcon[];
19}
20 
21function validateManifest(manifest: WebAppManifest): string[] {
22 const errors: string[] = [];
23 
24 if (!manifest.name || manifest.name.length > 45) {
25 errors.push('name must be 1-45 characters');
26 }
27 if (!manifest.short_name || manifest.short_name.length > 12) {
28 errors.push('short_name must be 1-12 characters');
29 }
30 if (!manifest.icons.some(i => i.sizes === '192x192')) {
31 errors.push('Missing 192x192 icon');
32 }
33 if (!manifest.icons.some(i => i.sizes === '512x512')) {
34 errors.push('Missing 512x512 icon');
35 }
36 if (!manifest.icons.some(i => i.purpose === 'maskable')) {
37 errors.push('Missing maskable icon');
38 }
39 if (!manifest.start_url) {
40 errors.push('start_url is required');
41 }
42 
43 return errors;
44}
45 
46const manifest = JSON.parse(readFileSync('public/manifest.json', 'utf-8'));
47const errors = validateManifest(manifest);
48if (errors.length > 0) {
49 console.error('Manifest validation errors:', errors);
50 process.exit(1);
51}
52console.log('Manifest is valid');
53 

Installability Detection and Prompt

typescript
1// src/app/install-prompt.ts
2 
3class InstallManager {
4 private deferredPrompt: BeforeInstallPromptEvent | null = null;
5 private isInstalled = false;
6 
7 initialize(): void {
8 window.addEventListener('beforeinstallprompt', (event) => {
9 event.preventDefault();
10 this.deferredPrompt = event as BeforeInstallPromptEvent;
11 this.showInstallButton();
12 });
13 
14 window.addEventListener('appinstalled', () => {
15 this.isInstalled = true;
16 this.hideInstallButton();
17 this.trackInstall();
18 });
19 
20 // Check if already installed
21 if (window.matchMedia('(display-mode: standalone)').matches) {
22 this.isInstalled = true;
23 }
24 }
25 
26 async promptInstall(): Promise<'accepted' | 'dismissed'> {
27 if (!this.deferredPrompt) {
28 throw new Error('Install prompt not available');
29 }
30 
31 this.deferredPrompt.prompt();
32 const { outcome } = await this.deferredPrompt.userChoice;
33 this.deferredPrompt = null;
34 return outcome;
35 }
36 
37 private showInstallButton(): void {
38 document.getElementById('install-btn')?.classList.remove('hidden');
39 }
40 
41 private hideInstallButton(): void {
42 document.getElementById('install-btn')?.classList.add('hidden');
43 }
44 
45 private trackInstall(): void {
46 navigator.sendBeacon('/api/analytics', JSON.stringify({
47 event: 'pwa_installed',
48 timestamp: Date.now(),
49 }));
50 }
51}
52 
53interface BeforeInstallPromptEvent extends Event {
54 prompt(): Promise<void>;
55 userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
56}
57 

Offline Status Management

typescript
1// src/app/offline-manager.ts
2 
3type ConnectionStatus = 'online' | 'offline' | 'slow';
4type StatusListener = (status: ConnectionStatus) => void;
5 
6class OfflineManager {
7 private status: ConnectionStatus = 'online';
8 private listeners: Set<StatusListener> = new Set();
9 
10 initialize(): void {
11 window.addEventListener('online', () => this.updateStatus('online'));
12 window.addEventListener('offline', () => this.updateStatus('offline'));
13 
14 // Periodic connectivity check
15 setInterval(() => this.checkConnectivity(), 30000);
16 }
17 
18 onStatusChange(listener: StatusListener): () => void {
19 this.listeners.add(listener);
20 return () => this.listeners.delete(listener);
21 }
22 
23 getStatus(): ConnectionStatus {
24 return this.status;
25 }
26 
27 private async checkConnectivity(): Promise<void> {
28 if (!navigator.onLine) {
29 this.updateStatus('offline');
30 return;
31 }
32 
33 try {
34 const start = Date.now();
35 await fetch('/api/health', { method: 'HEAD', cache: 'no-store' });
36 const latency = Date.now() - start;
37 
38 this.updateStatus(latency > 2000 ? 'slow' : 'online');
39 } catch {
40 this.updateStatus('offline');
41 }
42 }
43 
44 private updateStatus(newStatus: ConnectionStatus): void {
45 if (this.status !== newStatus) {
46 this.status = newStatus;
47 this.listeners.forEach(listener => listener(newStatus));
48 }
49 }
50}
51 

Conclusion

TypeScript brings meaningful benefits to PWA development: typed service worker events prevent common bugs, shared interfaces between the main app and service worker ensure message passing contracts are maintained, and compile-time validation catches errors that would otherwise surface as runtime failures in production.

The key architectural insight is treating the service worker as a separate TypeScript project with its own compilation target and type definitions. The WebWorker lib provides accurate types for the service worker global scope, and a shared types module ensures the main application and service worker agree on message formats, cache names, and sync queue structures.

FAQ

Need expert help?

Building with mobile/frontend?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026