Back to Journal
Mobile/Frontend

Progressive Web Apps Best Practices for Startup Teams

Battle-tested best practices for Progressive Web Apps tailored to Startup teams, including anti-patterns to avoid and a ready-to-use checklist.

Muneer Puthiya Purayil 10 min read

Startups building Progressive Web Apps face a different set of constraints than enterprise teams. Budget is limited, team size is small, and the priority is shipping a functional product that works across devices without maintaining separate native codebases. These best practices focus on getting maximum impact from minimal PWA investment.

Start with the App Shell Pattern

The app shell pattern loads your UI skeleton instantly from cache while fetching dynamic content from the network. For startups, this provides the perceived performance of a native app without the complexity:

typescript
1// sw.ts
2const SHELL_CACHE = 'shell-v1';
3const SHELL_URLS = [
4 '/',
5 '/styles/app.css',
6 '/scripts/app.js',
7 '/images/logo.svg',
8];
9 
10self.addEventListener('install', (event: ExtendableEvent) => {
11 event.waitUntil(
12 caches.open(SHELL_CACHE)
13 .then(cache => cache.addAll(SHELL_URLS))
14 .then(() => (self as any).skipWaiting())
15 );
16});
17 
18self.addEventListener('fetch', (event: FetchEvent) => {
19 const url = new URL(event.request.url);
20 
21 // App shell: cache-first
22 if (SHELL_URLS.includes(url.pathname)) {
23 event.respondWith(
24 caches.match(event.request).then(cached => cached || fetch(event.request))
25 );
26 return;
27 }
28 
29 // API calls: network-first with cache fallback
30 if (url.pathname.startsWith('/api/')) {
31 event.respondWith(
32 fetch(event.request)
33 .then(response => {
34 const clone = response.clone();
35 caches.open('api-cache').then(cache => cache.put(event.request, clone));
36 return response;
37 })
38 .catch(() => caches.match(event.request) as Promise<Response>)
39 );
40 return;
41 }
42});
43 

This gives users instant loading on repeat visits and graceful offline behavior without building a complex sync system.

Web App Manifest Essentials

A complete manifest is the minimum requirement for installability. Skip common startup mistakes:

json
1{
2 "name": "YourApp — Project Management",
3 "short_name": "YourApp",
4 "start_url": "/?source=pwa",
5 "display": "standalone",
6 "background_color": "#ffffff",
7 "theme_color": "#1a1a2e",
8 "orientation": "any",
9 "icons": [
10 { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
11 { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
12 { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
13 ],
14 "screenshots": [
15 {
16 "src": "/screenshots/desktop.png",
17 "sizes": "1280x720",
18 "type": "image/png",
19 "form_factor": "wide"
20 },
21 {
22 "src": "/screenshots/mobile.png",
23 "sizes": "750x1334",
24 "type": "image/png",
25 "form_factor": "narrow"
26 }
27 ]
28}
29 

Key details startups miss:

  • start_url with tracking parameter: Know how many users open your PWA from the installed icon vs browser
  • Maskable icons: Without these, Android shows your icon on an ugly white circle background
  • Screenshots: Required for the enhanced install prompt on Android

Push Notifications Without the Overhead

Push notifications drive re-engagement, but most startups over-engineer the implementation. Start with the Web Push API directly:

typescript
1// Register for push
2async function subscribeToPush(): Promise<PushSubscription | null> {
3 const registration = await navigator.serviceWorker.ready;
4 const permission = await Notification.requestPermission();
5 
6 if (permission !== 'granted') return null;
7 
8 const subscription = await registration.pushManager.subscribe({
9 userAgent: true,
10 applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
11 });
12 
13 // Send subscription to your backend
14 await fetch('/api/push/subscribe', {
15 method: 'POST',
16 headers: { 'Content-Type': 'application/json' },
17 body: JSON.stringify(subscription),
18 });
19 
20 return subscription;
21}
22 
23// In service worker: handle push events
24self.addEventListener('push', (event: PushEvent) => {
25 const data = event.data?.json() ?? { title: 'New Update', body: 'Check your app' };
26 
27 event.waitUntil(
28 (self as any).registration.showNotification(data.title, {
29 body: data.body,
30 icon: '/icons/192.png',
31 badge: '/icons/badge-72.png',
32 data: { url: data.url || '/' },
33 actions: data.actions || [],
34 })
35 );
36});
37 
38self.addEventListener('notificationclick', (event: NotificationEvent) => {
39 event.notification.close();
40 const url = event.notification.data?.url || '/';
41 
42 event.waitUntil(
43 (self as any).clients.matchAll({ type: 'window' }).then((clients: any[]) => {
44 const existing = clients.find((c: any) => c.url === url);
45 if (existing) return existing.focus();
46 return (self as any).clients.openWindow(url);
47 })
48 );
49});
50 

Use a service like web-push (Node.js library) on the backend rather than building push infrastructure from scratch. At startup scale (< 10,000 users), this handles the load without a dedicated push service.

Lightweight Offline Support

Full offline-first architectures are expensive to build. For startups, implement selective offline support for the features users need most:

typescript
1class SimpleOfflineQueue {
2 private readonly DB_NAME = 'offline-queue';
3 private readonly STORE_NAME = 'actions';
4 
5 async addToQueue(action: { url: string; method: string; body: string }): Promise<void> {
6 const db = await this.openDB();
7 const tx = db.transaction(this.STORE_NAME, 'readwrite');
8 tx.objectStore(this.STORE_NAME).add({
9 ...action,
10 timestamp: Date.now(),
11 id: crypto.randomUUID(),
12 });
13 }
14 
15 async processQueue(): Promise<void> {
16 const db = await this.openDB();
17 const tx = db.transaction(this.STORE_NAME, 'readonly');
18 const items = await this.getAll(tx.objectStore(this.STORE_NAME));
19 
20 for (const item of items.sort((a, b) => a.timestamp - b.timestamp)) {
21 try {
22 const response = await fetch(item.url, {
23 method: item.method,
24 headers: { 'Content-Type': 'application/json' },
25 body: item.body,
26 });
27 
28 if (response.ok) {
29 const deleteTx = db.transaction(this.STORE_NAME, 'readwrite');
30 deleteTx.objectStore(this.STORE_NAME).delete(item.id);
31 }
32 } catch {
33 break; // Still offline, stop processing
34 }
35 }
36 }
37 
38 private openDB(): Promise<IDBDatabase> {
39 return new Promise((resolve, reject) => {
40 const request = indexedDB.open(this.DB_NAME, 1);
41 request.onupgradeneeded = () => {
42 request.result.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
43 };
44 request.onsuccess = () => resolve(request.result);
45 request.onerror = () => reject(request.error);
46 });
47 }
48 
49 private getAll(store: IDBObjectStore): Promise<any[]> {
50 return new Promise((resolve, reject) => {
51 const request = store.getAll();
52 request.onsuccess = () => resolve(request.result);
53 request.onerror = () => reject(request.error);
54 });
55 }
56}
57 
58// Sync when coming back online
59window.addEventListener('online', () => {
60 const queue = new SimpleOfflineQueue();
61 queue.processQueue();
62});
63 

This handles the 80% case — form submissions and CRUD operations work offline — without the complexity of a full CRDT-based sync system.

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

Performance Optimization on a Budget

Precache Critical Resources Only

typescript
1// Don't precache everything. Precache what users need immediately.
2const CRITICAL_ASSETS = [
3 '/',
4 '/app.js', // Main bundle
5 '/app.css', // Styles
6 '/icons/logo.svg', // Brand assets
7];
8 
9// Lazy-cache everything else on first use
10self.addEventListener('fetch', (event: FetchEvent) => {
11 event.respondWith(
12 caches.match(event.request).then(cached => {
13 const fetchPromise = fetch(event.request).then(response => {
14 if (response.ok) {
15 const clone = response.clone();
16 caches.open('runtime').then(cache => cache.put(event.request, clone));
17 }
18 return response;
19 });
20 return cached || fetchPromise;
21 })
22 );
23});
24 

Measure Real User Performance

typescript
1// Minimal RUM (Real User Monitoring) — no third-party dependency needed
2function reportMetrics(): void {
3 if (!('PerformanceObserver' in window)) return;
4 
5 new PerformanceObserver((list) => {
6 const entries = list.getEntries();
7 const metrics: Record<string, number> = {};
8 
9 for (const entry of entries) {
10 if (entry.entryType === 'largest-contentful-paint') {
11 metrics.lcp = Math.round(entry.startTime);
12 }
13 }
14 
15 if (Object.keys(metrics).length > 0) {
16 navigator.sendBeacon('/api/metrics', JSON.stringify({
17 ...metrics,
18 url: location.pathname,
19 connection: (navigator as any).connection?.effectiveType,
20 }));
21 }
22 }).observe({ type: 'largest-contentful-paint', buffered: true });
23}
24 

Checklist

  • Web App Manifest with maskable icons and screenshots
  • Service worker with app shell caching
  • Network-first strategy for API calls
  • start_url tracking parameter for install attribution
  • Push notification with permission prompt UX (not on first visit)
  • Simple offline queue for form submissions
  • Online/offline status indicator in UI
  • Performance monitoring with Core Web Vitals
  • HTTPS configured (required for service workers)
  • Lighthouse PWA audit score > 90

Anti-Patterns to Avoid

Requesting notification permission on first visit: This gets denied 90% of the time. Wait until the user has engaged with a feature that benefits from notifications, then explain the value before requesting.

Caching API responses without expiration: Your cache will grow unbounded. Use a runtime cache with a max entries limit or TTL-based eviction.

Building a custom sync engine before product-market fit: If you do not yet know which features users rely on offline, you are guessing. Ship basic offline support (queue and retry) first, then invest in sync for the features with proven offline demand.

Ignoring iOS Safari limitations: iOS does not support push notifications in PWAs on versions below 16.4. Background sync is limited. Test on real iOS devices — the simulator does not accurately reflect PWA behavior.

Conclusion

Startup PWAs should prioritize installability and perceived performance over feature parity with native apps. A cached app shell, basic offline support, and push notifications deliver 80% of the native app experience at 20% of the development cost.

Resist the temptation to build a comprehensive offline-first architecture before validating product-market fit. Ship a PWA that loads instantly, works without network for basic operations, and re-engages users through push notifications. That foundation is enough to compete with native apps while preserving the web's deployment advantages — no app store review cycles, instant updates, and a single codebase.

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