Development

Building PWAs Without Frameworks: Extended Deep Dive

Introduction: Progressive Web Apps as a Paradigm

Progressive Web Apps (PWAs) represent a fundamental shift in how we think about web applications. Rather than separate concerns—native apps for desktop, native apps for mobile, websites for browsers—PWAs blur these boundaries. A PWA is a website that behaves like an app: it works offline, loads instantly, and can be installed on your home screen.

The remarkable part is that building sophisticated PWAs doesn't require heavy frameworks. Vanilla JavaScript provides all necessary APIs. This article explores building production-grade PWAs using only web standards and vanilla JavaScript.

Foundation: Understanding Service Workers

What Service Workers Are

A Service Worker is a JavaScript file that runs in the background, separate from your main website. It intercepts network requests, enabling offline functionality and caching strategies. Think of it as a proxy between your website and the network.

Service Workers are event-driven: they listen for installation events, activation events, and fetch events. When a user navigates your website and makes a request (for HTML, CSS, JavaScript, images), the Service Worker intercepts that request and decides what to do: return cached content, fetch from network, or some combination.

Registration: Starting Point

First, we register the Service Worker in our main application:

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); }); }

This checks browser support, then registers the Service Worker file. The registration happens asynchronously—the website continues loading while the Service Worker registers in the background.

Service Worker Lifecycle: Installation and Activation

Installation Phase

When first registered, the Service Worker fires an "install" event. This is where we typically cache critical assets needed for the app to function offline:

const CACHE_VERSION = 'v1'; const ASSETS_TO_CACHE = [ '/', '/index.html', '/styles.css', '/app.js', '/assets/logo.png' ]; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_VERSION).then(cache => { return cache.addAll(ASSETS_TO_CACHE); }) ); });

The `event.waitUntil()` tells the browser to keep the Service Worker alive until the promise resolves. We open a cache (named 'v1') and add all critical assets. If any fail, the entire installation fails.

Activation Phase

After installation, the Service Worker activates. This is where we clean up old caches:

self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { if (cacheName !== CACHE_VERSION) { return caches.delete(cacheName); } }) ); }) ); });

This finds all cache versions and deletes any that aren't the current version. This prevents old caches from consuming storage indefinitely.

Caching Strategies: The Core of PWAs

Strategy 1: Cache First, Fallback to Network

For static assets (CSS, JavaScript, images), cache-first makes sense. Serve from cache if available; if not, fetch from network and cache for future use:

self.addEventListener('fetch', event => { // Cache static assets if (event.request.url.includes('/assets/')) { event.respondWith( caches.match(event.request) .then(response => { if (response) return response; return fetch(event.request) .then(response => { // Clone the response before caching const cloned = response.clone(); caches.open(CACHE_VERSION) .then(cache => cache.put(event.request, cloned)); return response; }); }) .catch(() => { // Return fallback if both cache and network fail return caches.match('/fallback.png'); }) ); } });

This strategy ensures fast loads (cache) while keeping content fresh (network fetch).

Strategy 2: Network First, Fallback to Cache

For API responses and dynamic content, network-first makes sense. Try to fetch fresh data; if offline or network fails, use cached version:

self.addEventListener('fetch', event => { // Network-first for API calls if (event.request.url.includes('/api/')) { event.respondWith( fetch(event.request) .then(response => { const cloned = response.clone(); caches.open(CACHE_VERSION) .then(cache => cache.put(event.request, cloned)); return response; }) .catch(() => { return caches.match(event.request) .then(response => response || notFoundResponse()); }) ); } });

This ensures data freshness when possible while maintaining offline functionality.

Strategy 3: Stale-While-Revalidate

Return cached content immediately while fetching fresh content in background:

self.addEventListener('fetch', event => { event.respondWith( caches.open(CACHE_VERSION).then(cache => { return cache.match(event.request) .then(response => { // Start background fetch const fetchPromise = fetch(event.request) .then(networkResponse => { cache.put(event.request, networkResponse.clone()); return networkResponse; }); // Return cached response immediately, or network response return response || fetchPromise; }); }) ); });

This provides instant experience (cached response) while updating in background. Perfect for content that doesn't need to be perfectly fresh.

Key Principle: Different resources need different caching strategies. Static assets benefit from cache-first. API responses benefit from network-first. The goal is balancing freshness with reliability and performance.

Offline Functionality: Making Apps Work Everywhere

Building a Resilient App Shell

The "app shell" is the minimal HTML/CSS/JavaScript needed to render your app structure. Cache this aggressively. When offline, serve the shell and populate with cached data:

// In main app.js document.addEventListener('DOMContentLoaded', () => { if (!navigator.onLine) { document.body.classList.add('offline-mode'); loadCachedData(); } }); window.addEventListener('online', () => { document.body.classList.remove('offline-mode'); syncPendingChanges(); }); window.addEventListener('offline', () => { document.body.classList.add('offline-mode'); });

This detects offline state and adjusts UI accordingly. Users know they're offline and understand which features work.

Caching User Data

For PWAs to work offline, we need to cache user data. IndexedDB provides this capability:

// Initialize IndexedDB const dbRequest = indexedDB.open('MyAppDB', 1); dbRequest.onsuccess = (e) => { const db = e.target.result; const transaction = db.transaction(['posts'], 'readwrite'); const store = transaction.objectStore('posts'); // Store user data store.add({ id: 1, title: 'My Post', content: 'Post content', timestamp: Date.now() }); }; // Retrieve cached data function getCachedPost(id) { const db = indexedDB.open('MyAppDB'); const transaction = db.transaction(['posts'], 'readonly'); const store = transaction.objectStore('posts'); return store.get(id); }

IndexedDB provides structured storage for complex data, much better than localStorage for large datasets.

Progressive Enhancement: Layers of Capability

Design Philosophy

Progressive enhancement means building your PWA in layers: core functionality works with basic HTML, better experience with CSS, best experience with JavaScript and Service Workers.

A blog PWA might have layers:

  • Layer 1 (HTML): Server renders HTML with posts. Works in any browser, even very old ones.
  • Layer 2 (CSS): Styles render properly on all screen sizes. Responsive design makes it work on mobile and desktop.
  • Layer 3 (JavaScript): Smooth interactions, infinite scroll, instant search. Enhances user experience.
  • Layer 4 (Service Workers): Offline functionality, app-like installation, background sync. Most advanced capability.

Each layer enhances the experience, but core functionality works at every level.

Implementation Example

Build forms that work without JavaScript:

Web App Manifest: Installation and Identity

What Goes in the Manifest

The manifest.json file tells browsers how to display your PWA as an installed app:

{ "name": "My Awesome App", "short_name": "MyApp", "description": "An amazing progressive web app", "start_url": "/", "scope": "/", "display": "standalone", "theme_color": "#667eea", "background_color": "#ffffff", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } ] }

Linking in HTML

Reference the manifest in your HTML head:

This enables installation on home screen on Android and iOS.

Performance Optimization: Making PWAs Blazingly Fast

Critical Path Optimization

Cache only what's critical for initial render. Lazy-load everything else:

// Prioritize critical assets const CRITICAL_ASSETS = [ '/', '/index.html', '/critical.css', '/critical.js' ]; // Lazy-load others on-demand function lazyLoadAsset(src) { return caches.match(src).then(response => { if (response) return response; return fetch(src); }); }

Code Splitting and Module Loading

Use dynamic imports to load code only when needed:

// Load analytics only if enabled async function initializeAnalytics() { if (localStorage.getItem('analytics-enabled')) { const { initAnalytics } = await import('./analytics.js'); initAnalytics(); } } // Routes load their components dynamically async function navigateTo(route) { const { renderPage } = await import(`./pages/${route}.js`); renderPage(); }

Testing and Debugging PWAs

Chrome DevTools Integration

DevTools provides Service Worker inspection and testing:

  • Go to Application tab → Service Workers to see registration status
  • Go to Application tab → Cache Storage to inspect cached content
  • Use Network tab → Offline checkbox to test offline functionality
  • Go to Application tab → Manifest to verify manifest.json is valid

Lighthouse Auditing

Lighthouse provides automated PWA quality checks:

// Run in DevTools: // 1. Go to Lighthouse tab // 2. Select "Progressive Web App" // 3. Generate report // Report shows: // - Installability // - Service Worker functionality // - Offline support // - Performance metrics

Aim for 90+ score across all PWA metrics.

Pro Tip: Test on actual devices and slow networks. Simulation isn't perfect. Chrome DevTools lets you throttle network speed and test realistic conditions.

Handling Updates and Versioning

Semantic Cache Versioning

Use versioning to control when caches update:

const CACHE_VERSION = 'v2.1.0'; const OLD_CACHES = ['v1', 'v2.0.0']; self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(name => { if (OLD_CACHES.includes(name)) { return caches.delete(name); } }) ); }) ); });

Update Notifications

Notify users when new version is available:

let refreshing = false; navigator.serviceWorker.addEventListener('controllerchange', () => { if (refreshing) return; refreshing = true; window.location.reload(); }); navigator.serviceWorker.ready.then(reg => { reg.addEventListener('updatefound', () => { const newWorker = reg.installing; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // New version available showUpdateNotification(); } }); }); // Check for updates periodically setInterval(() => reg.update(), 60000); });

Conclusion: The Future of Web Applications

Progressive Web Apps built with vanilla JavaScript demonstrate that frameworks aren't always necessary. Understanding web standards deeply—Service Workers, Caching, IndexedDB, Offline APIs—enables you to build sophisticated, resilient applications.

The key is thoughtful architecture: consider caching strategies, offline requirements, performance constraints, and user experience. Build progressively, layer capabilities, and test thoroughly. The result is applications that work reliably whether users have perfect connectivity or no connectivity at all.

Ready to Build Better Web Apps?

Connect with me on LinkedIn to discuss web development and progressive web application strategies.