Free Ebook cover Progressive Web Apps (PWA) in Practice: Offline-First, Installable Web Apps with Service Workers and Web App Manifests

Progressive Web Apps (PWA) in Practice: Offline-First, Installable Web Apps with Service Workers and Web App Manifests

New course

19 pages

Security and Reliability: HTTPS, Safe Service Worker Practices, and Error Handling

Capítulo 12

Estimated reading time: 0 minutes

+ Exercise

Why security and reliability are inseparable in PWAs

Progressive Web Apps rely on capabilities that sit close to the network boundary: service workers can intercept requests, respond from cache, and influence what the user sees when the network is slow or unavailable. That power is exactly why browsers require strong security guarantees and why you must treat reliability as a security concern. A broken caching rule can “freeze” a vulnerable script in users’ caches; a missing error handler can turn a transient outage into a permanent blank screen; a misconfigured HTTPS setup can block installation, disable service workers, or expose users to man-in-the-middle attacks.

This chapter focuses on three pillars: (1) HTTPS and transport security, (2) safe service worker practices that reduce risk and avoid self-inflicted outages, and (3) robust error handling and observability so failures are contained, diagnosable, and recoverable.

HTTPS requirements and what “secure context” really means

Most PWA features require a secure context, which typically means the page is served over HTTPS. Service workers, push, background sync, and many storage APIs are either restricted or behave differently on insecure origins. In practice, you should assume that if your app is not consistently reachable via HTTPS, core PWA functionality will be unreliable.

What HTTPS protects (and what it doesn’t)

  • Confidentiality: prevents passive observers from reading traffic (e.g., session cookies, API responses).
  • Integrity: prevents tampering with scripts, HTML, and assets in transit. This is critical for service workers because a modified service worker script can permanently compromise the origin until it’s replaced.
  • Authentication: helps users know they’re talking to the right server (certificate validation).
  • Doesn’t replace application security: you still need authentication/authorization, input validation, and safe storage of tokens.

Local development exception

Browsers treat http://localhost (and sometimes http://127.0.0.1) as a secure context for development. Do not rely on this exception in staging or production; test your production-like environment over HTTPS early to avoid surprises with service worker registration and caching behavior.

Step-by-step: hardening HTTPS for a PWA

1) Redirect HTTP to HTTPS consistently

Ensure every request is redirected to HTTPS, including deep links. Inconsistent redirects can cause mixed content, broken installability checks, and duplicate caches across origins.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Example (Express behind a proxy):

app.enable('trust proxy'); // if behind load balancer / reverse proxy

app.use((req, res, next) => {
  if (req.secure) return next();
  res.redirect(301, 'https://' + req.headers.host + req.originalUrl);
});

Use a permanent redirect (301) only when you are sure the HTTPS endpoint is stable. During migrations, you may temporarily use 302/307 to avoid caching a wrong redirect.

2) Enable HSTS (HTTP Strict Transport Security)

HSTS tells browsers to always use HTTPS for your domain for a period of time. This reduces downgrade attacks and prevents users from accidentally hitting HTTP.

Recommended header (start conservatively, then increase):

Strict-Transport-Security: max-age=31536000; includeSubDomains

Operational tip: don’t enable preload until you fully understand the implications and have HTTPS working on all subdomains. Preload is hard to undo.

3) Avoid mixed content

Mixed content occurs when an HTTPS page loads resources over HTTP. Modern browsers block active mixed content (scripts, iframes) and warn on passive mixed content (images). In PWAs, mixed content can also break service worker caching assumptions and cause installability failures.

Practical checks:

  • Ensure API endpoints are HTTPS.
  • Ensure third-party scripts, fonts, and analytics are HTTPS.
  • Use protocol-relative URLs only if you understand the risks; explicit https:// is clearer.

4) Set secure cookies and session settings

If you use cookies for auth, set:

  • Secure so cookies are only sent over HTTPS.
  • HttpOnly to prevent JavaScript access (mitigates XSS token theft).
  • SameSite=Lax or SameSite=Strict depending on your flows (mitigates CSRF).

Example (Node/Express cookie options):

res.cookie('session', token, {
  secure: true,
  httpOnly: true,
  sameSite: 'lax',
  path: '/',
});

5) Add baseline security headers

Security headers reduce the blast radius of common web attacks. For PWAs, they also help ensure your service worker and cached assets aren’t easily abused.

  • Content Security Policy (CSP): reduces XSS risk by restricting script sources.
  • X-Content-Type-Options: nosniff: prevents MIME sniffing.
  • Referrer-Policy: controls referrer leakage.
  • Permissions-Policy: restricts access to powerful APIs.

A minimal CSP example (adjust to your needs):

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Practical note: If you use inline scripts or styles, you’ll need nonces/hashes. Avoid 'unsafe-inline' in production when possible.

Safe service worker practices

A service worker is a privileged script: it can intercept fetches for its scope and persist across sessions. Safe practices are about minimizing what can go wrong and ensuring you can recover quickly when it does.

Principle: keep the service worker small and deterministic

Complex logic in the service worker increases the chance of edge-case failures that are hard to reproduce. Prefer a small, well-tested routing layer and push complex decisions to the page or the server.

  • Keep dependencies minimal.
  • Avoid dynamic code loading inside the service worker.
  • Prefer explicit allowlists for what you cache and intercept.

Principle: never cache sensitive, user-specific responses

Cache Storage is shared per origin and can persist beyond a session. If you cache responses that include personal data, you risk exposing it to other users on shared devices or after logout.

Rules of thumb:

  • Do not cache responses that require authentication unless you have a clear, safe design.
  • Do not cache endpoints that return user-specific data unless you include strong cache partitioning and explicit invalidation on logout.
  • Be careful with GraphQL endpoints or “/api/me” style endpoints; they often contain personal data.

If you must cache authenticated data for offline use, treat it as a deliberate feature with explicit encryption-at-rest considerations and logout cleanup. At minimum, clear relevant caches on logout.

Principle: respect server caching headers and vary correctly

Blindly caching everything can store error pages, redirects, or responses meant to be transient. A safer approach is to cache only successful responses and to consider headers like Cache-Control and Vary.

Example: cache only “OK” same-origin GET responses and skip opaque responses:

self.addEventListener('fetch', (event) => {
  const req = event.request;
  if (req.method !== 'GET') return;

  const url = new URL(req.url);
  if (url.origin !== self.location.origin) return; // avoid caching third-party by default

  event.respondWith((async () => {
    try {
      const res = await fetch(req);
      // Only cache basic (same-origin) successful responses
      if (res.ok && res.type === 'basic') {
        const cache = await caches.open('runtime-v1');
        cache.put(req, res.clone());
      }
      return res;
    } catch (err) {
      const cache = await caches.open('runtime-v1');
      const cached = await cache.match(req);
      if (cached) return cached;
      throw err;
    }
  })());
});

This pattern reduces the risk of caching cross-origin opaque responses (which you can’t inspect) and avoids caching non-OK responses.

Principle: avoid caching redirects and error responses

Redirects (3xx) and server errors (5xx) can be temporary. If cached, they can create long-lived failures. Always check response.ok before caching. If you need to cache a redirect target, cache the final response instead of the redirect.

Principle: implement timeouts and “network is hanging” protection

Fetch can hang on flaky networks. Without a timeout, your service worker may keep the user waiting indefinitely. Implement a timeout wrapper for network-first routes so you can fall back to cache quickly.

async function fetchWithTimeout(request, ms) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), ms);
  try {
    return await fetch(request, { signal: controller.signal });
  } finally {
    clearTimeout(id);
  }
}

Use it in a handler:

try {
  const res = await fetchWithTimeout(event.request, 4000);
  return res;
} catch (e) {
  const cached = await caches.match(event.request);
  if (cached) return cached;
  return new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/plain' } });
}

Principle: protect against service worker “bricking”

A bad service worker can break navigation for every user in scope. You need a recovery strategy.

  • Keep a safe navigation fallback: if your fetch handler throws, return a minimal offline response or a cached fallback instead of letting the request fail silently.
  • Use feature flags for risky behavior: gate new caching logic behind a server-controlled flag so you can disable it quickly.
  • Version caches and clean up carefully: remove old caches only after the new worker is confirmed working.
  • Provide an escape hatch: in extreme cases, you can ship a “kill switch” service worker that unregisters itself (served as the service worker script), but this must be planned and tested.

Example “kill switch” approach (use only as an emergency tool):

// sw.js (emergency version)
self.addEventListener('install', (event) => {
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(keys.map((k) => caches.delete(k)));
    await self.registration.unregister();
    // Clients will fall back to network after reload
  })());
});

Important: this only works if clients can successfully fetch the updated sw.js from the network. If your current service worker blocks that request, you may need to ensure sw.js is always fetched from the network and never cached by your own logic.

Principle: ensure the service worker script itself is not cached incorrectly

The browser has special update checks for the service worker script, but intermediate caches (CDNs, proxies) can still cause trouble if they serve stale sw.js. Configure your server/CDN so sw.js is always revalidated.

Common recommendation:

  • Serve sw.js with Cache-Control: no-cache (allows caching but forces revalidation).
  • Avoid long-lived immutable caching for sw.js.

Example header:

Cache-Control: no-cache

Principle: validate request destinations and limit interception

Not every request should be intercepted. A common safe baseline is to handle only navigations and static assets, and let other requests pass through unless explicitly needed.

Example: only handle navigation requests:

self.addEventListener('fetch', (event) => {
  if (event.request.mode !== 'navigate') return;

  event.respondWith((async () => {
    try {
      return await fetch(event.request);
    } catch (e) {
      const fallback = await caches.match('/offline.html');
      return fallback || new Response('Offline', { status: 503 });
    }
  })());
});

This reduces the chance you accidentally cache API responses or third-party content.

Error handling that keeps the app usable

Reliability is not “no errors”; it’s “errors are expected and handled.” In PWAs, you need coordinated error handling across three layers: the page (UI), the service worker (network boundary), and the server (source of truth).

Classify failures: offline, slow, server error, and corrupted state

  • Offline: fetch fails with a network error; show offline UI and use cached content where safe.
  • Slow: fetch doesn’t fail but takes too long; use timeouts and show loading states.
  • Server error (5xx): network works but backend is failing; avoid caching the error response and show a retry UI.
  • Auth error (401/403): handle by redirecting to login or refreshing tokens; do not cache.
  • Corrupted state: caches contain incompatible assets; recover by clearing specific caches and reloading.

Step-by-step: implement a safe “retry and fallback” pattern

For navigations and key content requests, implement:

  • Attempt network with a timeout.
  • If network succeeds with 2xx, return it (and optionally update cache).
  • If network fails or times out, return cached content if available.
  • If neither is available, return a minimal fallback response.

Service worker example combining these rules:

async function safeNavigate(request) {
  const cache = await caches.open('pages-v1');

  try {
    const res = await fetchWithTimeout(request, 5000);
    if (res.ok) {
      cache.put(request, res.clone());
    }
    return res;
  } catch (e) {
    const cached = await cache.match(request);
    if (cached) return cached;
    const offline = await caches.match('/offline.html');
    return offline || new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/plain' } });
  }
}

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(safeNavigate(event.request));
  }
});

Notice the caching rule: only cache when res.ok. If the server returns a 500 page, it will not be stored as the “latest good” navigation.

Handle partial failures in the UI

Even with a good service worker, your UI should assume that some resources fail. Practical UI patterns:

  • Inline retry: show a “Retry” button next to failed components (e.g., a feed section) rather than failing the whole page.
  • Stale indicators: if you show cached content, label it as potentially outdated and provide a refresh action.
  • Graceful degradation: if a non-critical widget fails, hide it and keep core flows working.

Example: fetch with explicit error states in the page:

async function loadProfile() {
  const el = document.querySelector('#profile');
  el.textContent = 'Loading...';

  try {
    const res = await fetch('/api/profile', { headers: { 'Accept': 'application/json' } });
    if (!res.ok) throw new Error('Server error: ' + res.status);
    const data = await res.json();
    el.textContent = data.name;
  } catch (e) {
    el.textContent = 'Could not load profile.';
    document.querySelector('#retry').hidden = false;
  }
}

Don’t let error pages poison caches

A subtle reliability bug is caching HTML error responses for routes that normally return app content. This can happen if your backend returns a branded 200 OK error page (instead of a 500) or if a CDN returns an “access denied” page with 200. Mitigations:

  • Ensure the server uses correct status codes for errors.
  • In the service worker, validate responses before caching (status, content-type, and possibly a lightweight signature).
  • For HTML navigations, consider checking Content-Type includes text/html and that the response URL matches expectations.

Example validation before caching:

function isCacheableHTML(response) {
  if (!response || !response.ok) return false;
  const ct = response.headers.get('Content-Type') || '';
  return ct.includes('text/html');
}

Security pitfalls specific to service workers

Cache poisoning and untrusted content

Cache poisoning occurs when an attacker causes a malicious response to be stored and served later. In PWAs, risks increase if you cache responses without validating origin, status, and headers. Reduce risk by:

  • Caching only same-origin responses by default.
  • Not caching opaque cross-origin responses unless you have a strong reason.
  • Not caching responses to requests that include credentials unless designed for it.
  • Using CSP to reduce the impact of injected scripts.

Overbroad scope and unintended interception

If your service worker scope covers more paths than intended, it can intercept admin pages, auth callbacks, or other sensitive routes. Keep scope as narrow as practical and explicitly bypass interception for sensitive endpoints.

Example bypass list:

const BYPASS_PREFIXES = ['/admin', '/auth/callback'];

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (BYPASS_PREFIXES.some((p) => url.pathname.startsWith(p))) return;
  // handle other requests
});

Storing secrets in caches

Never store access tokens, refresh tokens, or other secrets in Cache Storage. Cache Storage is not designed as a secret vault. Prefer HttpOnly cookies for sessions or other secure token handling patterns, and keep sensitive data out of caches and logs.

Operational reliability: monitoring, logging, and recovery

Capture service worker errors

Service workers run in a separate context. Failures may not appear in your page logs. Add explicit logging and reporting hooks.

Example: basic error reporting from the service worker to your backend (ensure you don’t create infinite loops by reporting via a bypassed endpoint):

async function reportSWError(message, extra) {
  try {
    await fetch('/sw-error-report', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message, extra, ts: Date.now() }),
    });
  } catch (e) {
    // swallow to avoid cascading failures
  }
}

self.addEventListener('error', (event) => {
  event.waitUntil(reportSWError('sw_error', { msg: event.message, file: event.filename }));
});

self.addEventListener('unhandledrejection', (event) => {
  event.waitUntil(reportSWError('sw_unhandledrejection', { reason: String(event.reason) }));
});

Tip: Ensure /sw-error-report is excluded from caching and interception to avoid recursion.

Detect and recover from bad cached versions

Sometimes a release introduces a bug that only affects cached clients. Recovery options include:

  • Server-side “minimum version” check: if the client is too old, respond with a page that forces refresh.
  • Client-side “hard refresh” prompt: detect repeated failures and suggest reload.
  • Targeted cache purge: delete only the problematic cache names rather than all storage.

Example: page-side recovery after repeated navigation failures (conceptual):

let failures = 0;

async function guardedFetch(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error('HTTP ' + res.status);
    failures = 0;
    return res;
  } catch (e) {
    failures++;
    if (failures >= 3) {
      // show UI: "Something went wrong. Reload"
      document.querySelector('#reload').hidden = false;
    }
    throw e;
  }
}

Plan for CDN and proxy behavior

Many reliability incidents come from caching layers outside your control: CDNs, corporate proxies, or ISP caches. For PWAs:

  • Ensure sw.js is revalidated (Cache-Control: no-cache).
  • Use immutable caching for fingerprinted assets (e.g., app.8c1f3.js) but not for HTML entry points.
  • Make sure error responses are not cached by intermediaries unless explicitly desired.

Checklist: secure and reliable defaults

  • Serve everything over HTTPS; redirect HTTP to HTTPS.
  • Enable HSTS (carefully) and eliminate mixed content.
  • Set secure cookies and baseline security headers (CSP, nosniff, etc.).
  • Keep the service worker small; intercept only what you intend.
  • Cache only safe, successful, same-origin responses; avoid caching redirects and errors.
  • Never cache sensitive user-specific responses unless explicitly designed and cleared on logout.
  • Add timeouts and fallbacks to prevent hanging requests.
  • Ensure sw.js is not served stale by CDNs/proxies.
  • Implement error reporting for service worker failures and avoid reporting loops.
  • Have a tested recovery plan (feature flags, targeted cache purge, emergency unregister).

Now answer the exercise about the content:

Which service worker caching approach best reduces the risk of long-lived failures and cache poisoning in a PWA?

You are right! Congratulations, now go to the next page

You missed! Try again.

Caching only successful, inspectable same-origin responses (and skipping redirects/errors) helps prevent storing temporary failures and reduces the risk of untrusted content being served later.

Next chapter

Debugging Service Workers and Caches with Chrome DevTools

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.