DEV Community

Cover image for **Master Service Workers, Caching & Push Notifications: Complete PWA Development Guide**
Aarav Joshi
Aarav Joshi

Posted on

**Master Service Workers, Caching & Push Notifications: Complete PWA Development Guide**

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Service Worker Registration

Establishing offline capabilities starts with service worker registration. I begin by checking if the browser supports service workers, then register the worker file during the window's load event. This approach ensures it doesn't compete with critical resources. The registration process creates a separate thread that runs independently from the main browser thread.

Lifecycle management is crucial. I handle the install event to precache essential assets and use skipWaiting() to activate the new worker immediately. For updates, I implement versioned caching strategies during activation. When updating assets, I use cache versioning to avoid conflicts with existing resources.

// Enhanced registration with update checks
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const reg = await navigator.serviceWorker.register('/sw.js');
      console.log('Service worker active');

      // Update detection
      reg.addEventListener('updatefound', () => {
        const newWorker = reg.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed') {
            showUpdateNotification();
          }
        });
      });
    } catch (error) {
      console.error('Registration failed:', error);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Scope control determines which pages the service worker controls. I set the scope explicitly during registration using the scope option. For multi-page applications, I ensure the service worker covers all necessary routes by placing it at the root directory.

Offline Caching Strategies

Precaching core assets during installation creates a reliable foundation. I define critical resources like HTML, CSS, JavaScript, and logos in a precache list. These load instantly on subsequent visits, regardless of network conditions. Cache-first strategies work best for static assets that rarely change.

For dynamic content like API responses, I prefer network-first approaches. When online, fresh data serves the user while updating the cache. During offline periods, cached responses provide continuity. I include cache expiration logic to automatically purge outdated content.

// Advanced caching with expiration
const CACHE_TTL = {
  static: 86400 * 30, // 30 days
  api: 300 // 5 minutes
};

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetchWithTimeout(event.request, 5000)
        .then(response => cacheResponse(event.request, response, 'api'))
        .catch(() => getCachedWithExpiry(event.request, 'api'))
    );
  } else {
    // Static assets
    event.respondWith(
      getCachedWithExpiry(event.request, 'static')
        .then(cached => cached || fetch(event.request))
    );
  }
});

async function getCachedWithExpiry(request, type) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);
  if (!cached) return null;

  const age = (Date.now() - new Date(cached.headers.get('date'))) / 1000;
  return age < CACHE_TTL[type] ? cached : null;
}
Enter fullscreen mode Exit fullscreen mode

Cache versioning prevents stale content issues. I include a version identifier in the cache name and delete old caches during activation. This technique ensures users always interact with current assets without manual clearing.

Background Synchronization

Deferred actions require robust queuing mechanisms. When network requests fail, I store them in IndexedDB with metadata like timestamps and retry counts. The sync event then processes this queue when connectivity resumes. I've found adding exponential backoff reduces server load during spotty connections.

Conflict resolution is critical for data integrity. For collaborative applications, I implement client-side version vectors or timestamps. When synchronizing, the server compares these markers to resolve conflicting updates.

// Enhanced sync with conflict handling
async function processSyncQueue() {
  const queue = await getPendingActions();
  for (const action of queue) {
    try {
      const serverVersion = await fetchVersion(action.recordId);
      if (action.clientVersion > serverVersion) {
        const result = await submitAction(action);
        await markActionComplete(action.id);
      } else {
        await resolveConflict(action, serverVersion);
      }
    } catch (error) {
      await incrementRetryCount(action.id);
      if (action.retries > 5) await flagFailedAction(action.id);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

User feedback maintains transparency during synchronization. I display status indicators like "Changes saved offline" when queueing actions. After successful sync, notifications confirm data persistence. For critical failures, I prompt users to review conflicts manually.

Push Notification Integration

Permission handling requires thoughtful timing. I request notification access after meaningful user interactions rather than on page load. Progressive enhancement allows features to degrade gracefully in unsupported browsers. When permissions change, I update server subscriptions accordingly.

// Permission flow with user context
function suggestNotifications() {
  const featuresUsed = localStorage.getItem('featureUsageCount');
  if (featuresUsed > 3 && Notification.permission === 'default') {
    showDialog('Get updates about your data?', {
      accept: () => requestNotificationPermission(),
      decline: () => trackPreference('notifications-off')
    });
  }
}

async function requestNotificationPermission() {
  const result = await Notification.requestPermission();
  if (result === 'granted') {
    const sub = await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(VAPID_KEY)
    });
    await saveSubscription(sub);
  }
}
Enter fullscreen mode Exit fullscreen mode

Cross-device management presents unique challenges. I store subscription metadata with user accounts rather than devices. When users log in from new devices, I reconcile notification preferences across their ecosystem. For web push, I include action buttons that deep-link into specific application states.

Installation Prompting

Detecting installation eligibility involves checking several criteria. I verify the site has a service worker, uses HTTPS, and includes a valid web app manifest. The beforeinstallprompt event provides the trigger mechanism. Rather than showing prompts immediately, I wait for engagement milestones.

// Contextual install prompts
let installEvent;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  installEvent = e;

  // Delay prompt until meaningful interaction
  document.querySelector('.purchase-complete').addEventListener('click', () => {
    showInstallPrompt();
  });
});

function showInstallPrompt() {
  if (!installEvent) return;
  installEvent.prompt();
  installEvent.userChoice.then(choice => {
    if (choice.outcome === 'accepted') {
      trackEvent('install_accepted');
    }
    installEvent = null;
  });
}
Enter fullscreen mode Exit fullscreen mode

Platform-specific customization improves acceptance rates. For iOS, I provide custom instructions since Safari doesn't support standard prompts. On desktop browsers, I emphasize keyboard shortcuts. I always explain benefits like offline access and desktop icons before prompting.

Client-Side Storage Optimization

Combining storage technologies maximizes efficiency. I use Cache API for static resources and IndexedDB for structured data. For relational data, libraries like Dexie.js simplify complex queries. Storage partitioning prevents single-origin bottlenecks.

// Storage management with quotas
async function manageStorage() {
  if (!navigator.storage) return;

  const quota = await navigator.storage.estimate();
  const usageRatio = (quota.usage / quota.quota) * 100;

  if (usageRatio > 85) {
    const caches = await caches.keys();
    caches.sort((a,b) => a.created - b.created);

    while (usageRatio > 70 && caches.length) {
      await caches.delete(caches.shift().name);
      // Recalculate usage
    }
  }
}

// Dexie.js example for structured data
const db = new Dexie('AppData');
db.version(1).stores({
  projects: '++id, name, lastModified',
  tasks: '++id, projectId, status'
});

async function syncLocalChanges() {
  const unsynced = await db.tasks.where('synced').equals(0).toArray();
  await Promise.all(unsynced.map(task => {
    return api.updateTask(task).then(() => 
      db.tasks.update(task.id, { synced: 1 })
    );
  }));
}
Enter fullscreen mode Exit fullscreen mode

Synchronization strategies maintain data consistency. I use incremental updates with change timestamps rather than full syncs. For offline writes, I generate client-side IDs that later map to server IDs. Conflict resolution rules prioritize either client or server versions based on data type.

App Shell Architecture

The app shell provides immediate visual structure. I design minimal HTML skeletons with placeholders for dynamic content. Critical CSS loads inline to avoid render-blocking. The shell remains consistent across routes, creating native-like transitions.

<!-- Shell structure -->
<body>
  <header><!-- Persistent navigation --></header>
  <main id="dynamic-content">
    <div class="skeleton-loader">
      <!-- Animated placeholders -->
    </div>
  </main>
  <footer><!-- Consistent footer --></footer>
</body>
Enter fullscreen mode Exit fullscreen mode

Dynamic content population happens after shell rendering. I fetch data asynchronously and inject it into predefined slots. For complex applications, I use frameworks with server-side rendering capabilities that progressively enhance the shell.

// Content hydration
async function loadPage() {
  showSkeleton();

  try {
    const [data, template] = await Promise.all([
      fetch('/data/current'),
      loadTemplate('/templates/page.mustache')
    ]);

    renderContent(data, template);
  } catch (error) {
    showErrorState();
  } finally {
    hideSkeleton();
  }
}
Enter fullscreen mode Exit fullscreen mode

Route consistency ensures seamless navigation. I cache previously visited pages for instant back/forward movement. For new routes, I display the skeleton while fetching resources. Transition animations bridge content changes, maintaining user context.

Performance Measurement

Critical metrics guide optimization efforts. I track Time To Interactive rather than just load events. Real user monitoring provides more accurate data than lab testing. Service worker efficiency metrics include boot time and interception latency.

// Performance monitoring
function trackPerf() {
  const perfData = {
    fcp: 0,
    tti: 0,
    cacheRatio: 0
  };

  // Core Web Vitals
  const po = new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      if (entry.name === 'first-contentful-paint') {
        perfData.fcp = entry.startTime;
      }
    }
  });
  po.observe({ type: 'paint', buffered: true });

  // Cache effectiveness
  caches.open(CACHE_NAME).then(cache => {
    cache.keys().then(requests => {
      Promise.all(requests.map(req => 
        cache.match(req).then(res => !!res)
      )).then(hits => {
        perfData.cacheRatio = hits.filter(Boolean).length / hits.length;
      });
    });
  });

  // Report periodically
  setInterval(() => sendMetrics(perfData), 30000);
}
Enter fullscreen mode Exit fullscreen mode

Storage analytics reveal optimization opportunities. I log cache hit/miss ratios and storage usage patterns. Performance budgets prevent regressions by failing builds when metrics exceed thresholds. Real-time dashboards help diagnose issues as they occur.

Accessibility Enhancements

Offline states require clear communication. I provide status indicators using ARIA live regions that announce connectivity changes. Keyboard navigation remains functional regardless of network conditions. Focus management ensures interactive elements remain accessible.

// Connectivity awareness
function initConnectivityUI() {
  const statusEl = document.getElementById('connection-status');

  function updateStatus() {
    const online = navigator.onLine;
    statusEl.textContent = online ? '' : 'Offline - working locally';
    statusEl.setAttribute('aria-hidden', online);

    // Toggle interactive states
    document.querySelectorAll('[data-online-only]').forEach(el => {
      el.disabled = !online;
      el.setAttribute('aria-disabled', !online);
    });
  }

  window.addEventListener('online', updateStatus);
  window.addEventListener('offline', updateStatus);
  updateStatus();
}

// Screen reader support for notifications
function showLocalNotification(message) {
  const alert = document.createElement('div');
  alert.setAttribute('role', 'alert');
  alert.textContent = message;
  document.body.appendChild(alert);

  setTimeout(() => alert.remove(), 5000);
}
Enter fullscreen mode Exit fullscreen mode

Assistive technology testing ensures compatibility. I verify all PWA features with screen readers like JAWS and VoiceOver. Semantic HTML provides necessary context without visual cues. Reduced motion preferences respect user configurations through CSS media queries.

These techniques collectively transform web applications into resilient, engaging experiences. They bridge the gap between web flexibility and native functionality. Careful implementation creates applications that work everywhere while feeling native everywhere.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)