Offline Support and Progressive Web Apps (PWAs)
A Complete Frontend System Design Guide - Theory, Patterns and Interview Focus
Modern users expect apps to work everywhere — on flaky Wi-Fi, in subways, on flights, and even completely offline. Progressive Web Apps (PWAs) bridge the gap between web and native apps by delivering installability, offline support, background sync, and push notifications — all through the browser.
This guide focuses on concepts, architecture patterns, trade-offs, and interview-relevant knowledge rather than exhaustive code. Where code appears, it's kept to minimal syntax to illustrate a concept.
Table of Contents
- What is a Progressive Web App (PWA)?
- Core PWA Building Blocks
- Service Worker Lifecycle Deep Conceptual Understanding
- Cache Strategies Deep Dive
- Offline First Architecture Patterns
- Client Side Storage Comparison and Mental Models
- App Shell Architecture
- Background Sync Concepts and Patterns
- Push Notifications How They Actually Work
- Web App Manifest and Installability
- Cache Versioning and Update Strategies
- Real World Architecture: Offline Capable App
- Testing and Debugging PWAs
- Trade offs, Gotchas and Platform Limitations
- Decision Frameworks
- Interview Questions and Answers
- Final Recommendations
What is a Progressive Web App (PWA)?
A PWA is not a single technology — it's a convergence of capabilities that make a web app behave like a native app. Think of it as a philosophy: progressively enhance a standard website with native-like features.
The Three Pillars
| Pillar | What It Provides | Why It Matters |
|---|---|---|
| Service Worker | Offline support, caching, background sync, push notifications | The "brain" — a programmable network proxy sitting between your app and the network |
| Web App Manifest | Installability, splash screen, app icon, standalone mode | The "identity" — makes the browser treat your site as an installable app |
| HTTPS | Secure context (mandatory for Service Workers) | The "trust layer" — no Service Worker can register without HTTPS |
Why PWAs Matter in System Design
Traditional Web App PWA
┌─────────────────┐ ┌─────────────────┐
│ Network-only │ │ Offline-capable│
│ Tab experience │ │ Installable │
│ No background │ │ Background sync│
│ No push │ │ Push-enabled │
│ Always online │ │ Resilient │
└─────────────────┘ └─────────────────┘
Key Insight for Interviews
A PWA doesn't mean "building an offline app." It means designing resilient web experiences that degrade gracefully. Even if you never need full offline support, Service Workers dramatically improve perceived performance through intelligent caching.
Browser Support Landscape
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Service Workers | ✅ | ✅ | ✅ | ✅ |
| Cache API | ✅ | ✅ | ✅ | ✅ |
| Web App Manifest | ✅ | ✅ | ✅ (partial) | ✅ |
| Background Sync | ✅ | ❌ | ❌ | ✅ |
| Push Notifications | ✅ | ✅ | ✅ (iOS 16.4+) | ✅ |
| Periodic Background Sync | ✅ | ❌ | ❌ | ✅ |
Interview takeaway: Background Sync and Periodic Sync are Chromium-only. Always design with fallbacks. Push notifications on iOS only work for installed PWAs since iOS 16.4.
Core PWA Building Blocks
┌──────────────────────────────────────────────────────────┐
│ Your Web App │
│ ┌─────────┐ ┌───────────┐ ┌────────────────────┐ │
│ │ App │ │ Web App │ │ Service Worker │ │
│ │ Shell │ │ Manifest │ │ ┌──────────────┐ │ │
│ │ (HTML/ │ │ (JSON) │ │ │ Cache API │ │ │
│ │ CSS/JS)│ │ │ │ │ IndexedDB │ │ │
│ │ │ │ │ │ │ Background │ │ │
│ │ │ │ │ │ │ Sync │ │ │
│ │ │ │ │ │ │ Push API │ │ │
│ └─────────┘ └───────────┘ │ └──────────────┘ │ │
│ └────────────────────┘ │
└──────────────────────────────────────────────────────────┘
│
┌─────┴──────┐
│ HTTPS │
└────────────┘
How These Pieces Relate (Mental Model)
Think of the architecture in layers:
- App Shell — The skeleton UI (header, nav, footer) that loads instantly from cache. It's the "frame" of your app, with no data content.
- Web App Manifest — A JSON file that tells the browser: "This website can be installed. Here's its name, icon, and how it should look when launched."
- Service Worker — A JavaScript file that runs in a separate thread from your main page. It acts as a programmable proxy — every network request your app makes passes through it, and you decide whether to serve from cache, network, or both.
- Cache API — A key-value store where keys are HTTP Requests and values are HTTP Responses. Used by the Service Worker to store and retrieve network responses.
- IndexedDB — A full client-side database for structured data (objects, arrays, blobs). Used for app data (posts, messages, user profiles) — not HTTP responses.
Key distinction: Cache API stores network responses (Request → Response). IndexedDB stores application data (structured objects). They serve different purposes and are often used together.
Service Worker Lifecycle Deep Conceptual Understanding
Understanding the lifecycle is the most important concept for interviews. It determines when your cache updates, when old versions are cleaned up, when new features activate, and when bugs can appear.
The Six States
┌──────────┐
│ Register │ ← Browser downloads SW file
└────┬─────┘
│
┌────▼─────┐
│ Install │ ← Precache critical assets here
└────┬─────┘
│
┌────▼──────┐
│ Waiting │ ← New SW waits for ALL old tabs to close
└────┬──────┘
│
┌────▼──────┐
│ Activate │ ← Clean old caches here
└────┬──────┘
│
┌────▼──────┐
│ Idle │ ← Listening for fetch/push/sync events
└────┬──────┘
│
┌────▼──────────┐
│ Terminated │ ← Browser can kill idle SW to save memory
└───────────────┘
Phase by Phase Explanation
1. Registration
- Your main page JS calls
navigator.serviceWorker.register('/sw.js'). - The browser downloads the SW file.
- Scope is determined by the SW file's location (or by an explicit
scopeoption). A SW at/sw.jscontrols the root'/'scope. - Registration should happen after page load to avoid competing for bandwidth.
2. Install
- Triggers the
installevent inside the SW. - This is where you precache your critical assets (app shell, offline fallback page).
- If any asset fails to cache, the entire installation fails and the SW goes to a
redundantstate. - The SW does NOT yet control any pages.
3. Waiting
- After install, the new SW enters a waiting state.
- Why? Because the old SW is still controlling open tabs. Activating the new SW would create inconsistencies (old HTML + new SW logic).
- The new SW will activate only when all tabs controlled by the old SW are closed.
4. Activate
- Triggers the
activateevent inside the SW. - This is where you clean up old caches (delete caches from previous versions).
- After activation, the SW is ready to intercept requests — but only for new navigations. Existing tabs that were open during activation won't be controlled unless you call
clients.claim().
5. Idle / Fetch
- The SW sits idle, waiting for events:
fetch,push,sync,message. - Every network request from controlled pages triggers the
fetchevent — you decide the response source.
6. Terminated
- The browser can kill an idle SW at any time to free memory.
- It will be restarted when the next relevant event occurs.
- This means: never store state in SW global variables — use IndexedDB or Cache API instead.
The Update Problem skipWaiting vs Controlled Updates
This is a very common interview topic:
| Approach | How It Works | Use When |
|---|---|---|
| Default (no skipWaiting) | New SW waits until all old tabs close | Safest — no version mismatch between HTML and SW |
skipWaiting() + clients.claim() |
New SW activates immediately and takes over all open tabs | Non-breaking cache changes (e.g., adding new images to precache) |
| Prompt-based update | Show "Update available" banner, user clicks to reload | Best UX for production apps with breaking changes |
Why skipWaiting() Can Be Dangerous
Imagine: User has Tab A open with v1 HTML. You deploy v2 which changes the API response format. If the new SW skipWaiting()s:
- Tab A is now controlled by the v2 Service Worker
- But it's still rendering v1 HTML/JS — which expects the old API format
- Result: broken app
The safe pattern: Detect the update, show a banner ("New version available — click to refresh"), and only skipWaiting() when the user explicitly chooses to update.
// Minimal syntax — Prompt-based update pattern
// In main.js: listen for 'updatefound' on registration
// When new SW is 'installed' but old SW is active → show banner
// On user click → postMessage({ type: 'SKIP_WAITING' }) to new SW
// In sw.js: listen for message → call self.skipWaiting()
// In main.js: listen for 'controllerchange' → window.location.reload()
Key Mental Model
Think of the Service Worker as a shared singleton that runs per-origin, not per-tab. All tabs on the same origin share one active SW. That's why the "waiting" phase exists — you can't swap the singleton while old tabs depend on it.
Cache Strategies Deep Dive
The Cache API is a key-value store for Request → Response pairs. Service Workers intercept fetch events and decide how to respond: from cache, from network, or both.
Strategy Overview
┌─────────────────────────────────────────────────────────────────┐
│ Cache Strategies │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ Cache First │ │Network First │ │ Stale-While- │ │
│ │ (Cache, │ │(Network, │ │ Revalidate │ │
│ │ fallback │ │ fallback │ │ (Cache + Background │ │
│ │ network) │ │ cache) │ │ Network Update) │ │
│ └──────────────┘ └──────────────┘ └───────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ Network Only │ │ Cache Only │ │ Race (Cache vs │ │
│ │ │ │ │ │ Network) │ │
│ └──────────────┘ └──────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
1. Cache First (Cache Falling Back to Network)
Best for: Static assets that rarely change — CSS bundles, JS bundles (with content hashes), images, fonts.
Request → Check Cache → HIT? → Return cached response
→ MISS? → Fetch from network → Store in cache → Return
How it works conceptually:
- The SW checks the Cache API first. If found, it returns immediately — zero network latency.
- On a miss, it fetches from the network, stores the response in cache for next time, and returns.
- The response must be cloned before caching because the Response body is a stream that can only be consumed once.
Trade-offs:
| Pros | Cons |
|---|---|
| Fastest possible response | Serves stale content until SW is updated |
| Works fully offline | Must version caches or use hashed filenames |
| Lowest bandwidth usage | Stale-forever risk if cache cleanup is neglected |
When to use: Versioned, hashed assets like app.a1b2c3.js, font files, icons. These files are immutable per version.
2. Network First (Network Falling Back to Cache)
Best for: Dynamic content that should be fresh — API responses, HTML pages, dashboards.
Request → Fetch from network → SUCCESS? → Store in cache → Return
→ FAIL? → Return cached (offline fallback)
How it works conceptually:
- Always try the network first for the freshest data.
- If the network succeeds, update the cache and return the response.
- If the network fails (offline, timeout), fall back to whatever is cached.
- Optionally add a network timeout — if the network takes more than N seconds, fall back to cache immediately.
Trade-offs:
| Pros | Cons |
|---|---|
| Always shows freshest data when online | Slower — always incurs network latency |
| Graceful offline fallback | Uses more bandwidth than cache-first |
| Simple mental model | Cold cache on first visit = slow |
When to use: News feeds, user profiles, search results, HTML pages.
Interview insight: Adding a networkTimeoutSeconds parameter makes this strategy more resilient — if the network is slow (not offline), you still get a fast response from cache rather than waiting.
3. Stale While Revalidate (SWR)
Best for: Semi-dynamic content where instant load matters but eventual freshness is acceptable.
Request → Check Cache → HIT? → Return cached (stale) IMMEDIATELY
→ ALSO fire network request in background
→ Update cache with fresh response
→ MISS? → Fetch from network → Store in cache → Return
How it works conceptually:
- Returns the cached (potentially stale) version instantly for a fast user experience.
- Simultaneously fetches from the network in the background.
- Updates the cache with the new response so the next request will get fresh data.
- Optionally, the SW can notify the page via
postMessage()that new data is available so the UI can refresh.
Trade-offs:
| Pros | Cons |
|---|---|
| Instant load + eventual freshness | User briefly sees stale data |
| Works offline (serves stale) | Complexity in notifying UI of updates |
| Best perceived performance for repeat visits | Two requests per navigation (cache + network) |
When to use: Social feeds, product listings, user avatars, recommendations, non-critical API data.
Interview insight: SWR is the most commonly used strategy in real production PWAs. It's a great balance between performance and freshness. React's useSWR hook and TanStack Query share similar philosophy at the application layer.
4. Network Only
Best for: Requests that must never be cached — auth tokens, payment processing, analytics.
Request → Fetch from network → Return (no caching at all)
When to use: /api/auth, /api/checkout, /analytics, CSRF token endpoints. Caching these could lead to security vulnerabilities or stale authentication states.
5. Cache Only
Best for: Precached assets that are guaranteed to be in cache from the install step.
Request → Return from cache (no network fallback)
When to use: Assets that were precached during the SW install event. Only works if you're certain the asset is in cache.
6. Race (Cache vs Network)
Best for: When you want the absolute fastest response regardless of source.
Request → Fire both cache lookup AND network fetch simultaneously
→ Return whichever resolves first
When to use: Latency-critical requests where both cached and fresh content are acceptable. Rarely used in practice because it wastes bandwidth.
Strategy Decision Matrix (Interview Ready)
| Strategy | Speed | Freshness | Offline | Best For |
|---|---|---|---|---|
| Cache First | ⚡ Instant | ❌ Stale until SW update | ✅ Full | Static assets (JS, CSS, images) |
| Network First | 🐢 Network-dependent | ✅ Always fresh online | ✅ Fallback | Dynamic content, HTML pages |
| Stale-While-Revalidate | ⚡ Instant | 🔄 Eventually fresh | ✅ Stale | Feeds, listings, avatars |
| Network Only | 🐢 Network-dependent | ✅ Always fresh | ❌ None | Auth, checkout, analytics |
| Cache Only | ⚡ Instant | ❌ Fixed | ✅ Full | Precached app shell assets |
| Race | ⚡ Fastest wins | 🔄 Depends | ✅ Partial | Latency-critical requests |
The Router Pattern (Combining Strategies)
In production, you never use a single strategy. You build a router that maps request types to strategies:
| Request Type | Strategy | Why |
|---|---|---|
HTML navigation (request.mode === 'navigate') |
Network First | Need fresh page content, cache as fallback |
Static assets (.js, .css, .woff2 with hashes) |
Cache First | Immutable per version — hash changes on update |
API calls (/api/*) |
Stale-While-Revalidate | Fast load + background refresh |
| Auth/payment endpoints | Network Only | Never cache sensitive data |
| Precached app shell assets | Cache Only | Guaranteed to be cached from install |
| User-uploaded images / CDN assets | Cache First + expiration | Cache with size and age limits |
// Conceptual router pattern (minimal syntax)
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') → networkFirst()
if (isStaticAsset(url)) → cacheFirst()
if (url.startsWith('/api/auth')) → networkOnly()
if (url.startsWith('/api/')) → staleWhileRevalidate()
// default → networkFirst()
});
Offline First Architecture Patterns
What is Offline First?
Offline-first means your app is designed to work offline by default and sync when connectivity returns — rather than treating offline as an error state.
Traditional Approach Offline-First Approach
───────────────────── ─────────────────────────
1. Fetch data from server 1. Read from local store (IndexedDB)
2. If offline → show error 2. Render UI immediately
3. User waits or retries 3. Sync with server in background
4. Merge changes when online
The Four Core Principles
Local-first reads — Always read from local storage (IndexedDB), never from the network for initial render. The network is used for sync, not reads.
Optimistic writes — Write to local storage immediately, show the result to the user, and queue the mutation for server sync. The UI should never wait for the network.
Background sync — Push queued mutations to the server when connectivity returns. Use the Background Sync API where available, or fall back to online/offline event listeners.
Conflict resolution — When the same data is modified on multiple devices while offline, you need a strategy to reconcile divergent states.
Architecture Diagram
┌─────────────────────────────────────────────┐
│ UI Layer │
│ (React, Vue, Angular, etc.) │
└──────────────────┬──────────────────────────┘
│ read/write
┌──────────────────▼──────────────────────────┐
│ Data Access Layer │
│ ┌──────────────────────────────────────┐ │
│ │ Local Store (IndexedDB) │ │
│ │ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ Data │ │ Mutation Queue │ │ │
│ │ │ Store │ │ (pending syncs) │ │ │
│ │ └──────────┘ └──────────────────┘ │ │
│ └──────────────────────────────────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ Sync Engine │ │
│ │ (online/ │ │
│ │ offline │ │
│ │ aware) │ │
│ └───────┬────────┘ │
└────────────────────┼────────────────────────┘
│
┌──────▼──────┐
│ Network │
│ (REST / │
│ GraphQL) │
└─────────────┘
Data Flow Pattern for Offline First Writes
The critical pattern to understand (and explain in interviews):
- User performs an action (e.g., creates a post, sends a message)
-
Write to IndexedDB immediately — assign a
clientId(UUID), setsyncStatus: 'pending' - Update UI instantly — the user sees their action reflected with an optimistic indicator (e.g., a clock icon, "Sending...")
-
Queue the mutation — add to a separate
syncQueueobject store in IndexedDB, containing the operation type (CREATE/UPDATE/DELETE), the data, and a timestamp -
If online → process the queue immediately:
- POST/PUT/DELETE to the server
- On success: update
syncStatusto'synced', remove from queue - On failure: leave in queue for retry
-
If offline → register a Background Sync event (or just wait for
onlineevent) - When connectivity returns → process the entire queue in FIFO order
Conflict Resolution Strategies
When the same record is modified offline on multiple devices, you need a merge strategy:
| Strategy | How It Works | Best For | Complexity |
|---|---|---|---|
| Last Write Wins (LWW) | Highest updatedAt timestamp wins, losing changes are discarded |
Simple apps, user settings, profiles | Low |
| Server Wins | Server version always takes priority over client changes | Admin-controlled data, configurations | Low |
| Client Wins | Client version always takes priority | Very rare — draft-only editing scenarios | Low |
| Field-Level Merge | Compare each field individually; only conflicting fields need resolution | Form-based data, structured records | Medium |
| Manual Merge | Show both versions to the user, let them choose or combine | Documents, collaborative editing | Medium |
| CRDTs | Conflict-free Replicated Data Types — mathematically guaranteed to merge without conflicts | Real-time collab (Figma, Notion, Linear) | High |
| Operational Transforms (OT) | Transform concurrent operations based on order and context | Google Docs, real-time text editing | Very High |
When Interviewers Ask About Conflict Resolution
The LWW (Last Write Wins) approach is sufficient for 80% of use cases. Mention it first, then discuss CRDTs/OT for collaborative scenarios:
- LWW — Simple timestamp comparison. Works great for settings, profiles, single-user data.
- Field-level merge — Compare a "base" version (before offline edits) with both local and server versions. Fields changed by only one side auto-resolve; fields changed by both sides require a policy (default to server, flag for user review).
- CRDTs — Special data structures (G-Counter, LWW-Register, OR-Set) that are designed to be merged from any direction without conflicts. Growing in adoption (Yjs, Automerge libraries).
Client Side Storage Comparison and Mental Models
The Storage Landscape
| Storage | Max Size | Sync/Async | Data Format | Accessible From | Best Use Case |
|---|---|---|---|---|---|
| Cookies | ~4 KB | Sync | String (key=value) | Server + Client | Auth tokens, server-readable preferences |
| localStorage | ~5 MB | Sync (blocks main thread!) | String only | Main thread only | Small settings, theme preference, simple flags |
| sessionStorage | ~5 MB | Sync | String only | Main thread (tab-only) | Tab-specific temp data, wizard step state |
| IndexedDB | 50 MB – GBs | Async (non-blocking) | JS objects, blobs, arrays | Main thread + Service Worker | App data, offline storage, large datasets |
| Cache API | Generous (varies) | Async | Request → Response pairs | Main thread + Service Worker | HTTP response caching for Service Workers |
Why IndexedDB is the Answer for Offline Apps
- Asynchronous — won't block the main thread (unlike localStorage)
-
Structured data — stores JavaScript objects directly (no
JSON.stringify) - Indexed queries — create indexes for fast lookups by field
- Large capacity — can store gigabytes of data
- Transaction-based — ACID-compliant operations (atomic, consistent, isolated, durable)
- Available in Service Workers — unlike localStorage, IndexedDB can be used during background sync and push handling
The localStorage Trap (Interview Red Flag)
Never use
localStoragefor offline app data. It's synchronous (blocks the main thread), limited to 5 MB, stores only strings (requiring serialization), and is not accessible from Service Workers. It's fine for a theme toggle — not for a data store.
IndexedDB Mental Model
Think of IndexedDB as a NoSQL database in the browser:
- Database → one per app/feature (you can have multiple)
-
Object Store → like a "collection" or "table" (e.g.,
posts,messages,syncQueue) -
Index → like a "secondary key" for fast lookups (e.g., index on
category,syncStatus) -
Transaction → all reads/writes happen inside a transaction (
readonlyorreadwrite) - Cursor → for iterating over records (like a database cursor)
// Minimal syntax — the essential pattern
const db = indexedDB.open('my-app', 1);
// onupgradeneeded → create object stores and indexes
// onsuccess → start read/write transactions
// transaction('posts', 'readwrite') → objectStore.put(data)
// transaction('posts', 'readonly') → objectStore.get(key) or getAll()
Tip: Raw IndexedDB is verbose and callback-based. In production, use the idb library (by Jake Archibald) — a tiny Promise wrapper that makes IndexedDB much cleaner to use.
Storage Quotas and Eviction
How much can you store?
- Chrome: Up to 80% of total disk space per origin
- Firefox: Up to 50% of total disk space
- Safari: ~1 GB, but can be evicted aggressively
What is eviction?
- When the browser runs low on disk space, it may delete stored data (Cache API, IndexedDB) from origins.
- Eviction follows LRU (Least Recently Used) — least-used origins are evicted first.
How to protect against eviction:
- Call
navigator.storage.persist()— requests the browser to treat your storage as persistent (won't be evicted). - Chrome auto-grants persistent storage for installed PWAs, bookmarked sites, or sites with push permissions.
- Always call
navigator.storage.estimate()to check available quota before storing large data.
App Shell Architecture
What is the App Shell?
The App Shell is the minimal HTML, CSS, and JavaScript required to render the structural UI — the navigation bar, sidebar, footer, and loading states — without any dynamic content data.
┌──────────────────────────────────────────┐
│ ┌──────────────────────────────────┐ │
│ │ Header / Navbar │ │ ← App Shell (cached)
│ └──────────────────────────────────┘ │
│ ┌────────┐ ┌───────────────────┐ │
│ │ │ │ │ │
│ │ Side │ │ Content Area │ │ ← Dynamic content
│ │ Nav │ │ (loaded from │ │ (fetched at runtime
│ │ │ │ network or │ │ from API / IndexedDB)
│ │ │ │ IndexedDB) │ │
│ │ │ │ │ │
│ └────────┘ └───────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ Footer / Tab Bar │ │ ← App Shell (cached)
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────┘
How It Works (Flow)
First Visit:
Browser → Server → Full page (HTML + CSS + JS)
→ Service Worker installs → Precaches the app shell assets
Repeat Visits:
Browser → Service Worker → Serves app shell from cache INSTANTLY (< 100ms)
→ JavaScript fetches content data from API or IndexedDB
→ Content fills the skeleton
Why It Matters
| Without App Shell | With App Shell |
|---|---|
| White screen until entire page loads | Instant structural UI from cache |
| Offline = blank page | Offline = app frame + cached/local data |
| FCP depends on network | FCP is near-instant on repeat visits |
| No separation of shell vs content | Clean architecture boundary |
App Shell in Framework Terms
Modern frameworks implement the App Shell concept naturally:
| Framework Concept | App Shell Equivalent |
|---|---|
layout.tsx / _layout.vue
|
The shell — nav, sidebar, footer |
page.tsx / route components |
Dynamic content that fills the shell |
loading.tsx / Suspense fallback |
Skeleton screens while content loads |
error.tsx / Error boundaries |
Error states within the shell |
Interview Ready Explanation
"The App Shell pattern separates your UI into two parts: the shell (structural chrome that's the same on every page) and the content (data-driven portions that change). The shell is precached during Service Worker install, giving users instant repeat loads. Content is loaded dynamically from the API or IndexedDB. Even offline, users see the full app frame — not a blank page."
Background Sync Concepts and Patterns
The Problem It Solves
User submits a form, sends a message, or likes a post — but they're on a flaky connection. Without Background Sync, that action fails silently or shows an error. With Background Sync, the action is queued locally and automatically retried when connectivity returns.
How Background Sync Works (Conceptual Flow)
User Action (offline)
│
▼
Save to IndexedDB → Register sync event → User can close the tab
│
┌───────────────────────┘
│ (later, when online)
▼
Service Worker wakes up
│
▼
Read pending actions from IndexedDB
│
▼
POST/PUT/DELETE to server
│
┌─────────┴────────┐
│ │
Success Failure
│ │
▼ ▼
Remove from Re-throw error
IndexedDB (browser retries
automatically)
Key Concepts
Tag-based registration — You register a sync with a string tag (
'send-messages'). If you register the same tag multiple times before it fires, it coalesces into a single sync event.Automatic retry — If the sync event handler throws an error, the browser will automatically retry with exponential backoff. You don't manage retries yourself.
Survives tab close — This is the key advantage over a simple
onlineevent listener. Background Sync fires even if the user has closed all tabs. The Service Worker wakes up to handle it.IndexedDB as the queue — You must store pending actions in IndexedDB (not in memory or localStorage). The Service Worker has no access to the page's memory, and it may be terminated and restarted at any time.
One Time Sync vs Periodic Background Sync
| Feature | One-Time Background Sync | Periodic Background Sync |
|---|---|---|
| Purpose | Retry a failed/queued action when back online | Periodically fetch fresh data in background |
| Trigger | Connectivity returns after being offline | Time interval (minimum ~12 hours) |
| Example | Send queued messages, submit forms | Refresh news feed, sync calendar |
| Tab required? | No — works after tab is closed | No — works in background |
| Browser support | Chrome, Edge | Chrome, Edge (high-engagement sites only) |
| Safari/Firefox | ❌ Not supported | ❌ Not supported |
Fallback Strategy (Critical for Interviews)
Since Background Sync is Chromium-only, you must always provide a fallback:
Primary path: Register background sync → SW handles on connectivity
Fallback path: Listen for 'online' event in main thread → retry queue manually
The pattern is: always write to IndexedDB first, then try to sync. Whether sync happens via Background Sync API or via an online event listener is an implementation detail.
// Minimal syntax — Background Sync registration
// In main.js:
// 1. Save action to IndexedDB 'outbox' store
// 2. registration.sync.register('send-messages')
// In sw.js:
// self.addEventListener('sync', event => {
// if (event.tag === 'send-messages') {
// event.waitUntil( processOutbox() )
// }
// })
// Fallback in main.js:
// window.addEventListener('online', () => processOutbox())
Push Notifications How They Actually Work
The Architecture (Three Servers Involved)
Push notifications involve three parties — this is important to understand:
Your Application Push Service User's Browser
┌──────────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ │ │ │ │ │
│ ┌──────────┐ ┌─────────┐ │ │ FCM / APNs / │ │ Service Worker │
│ │ Client │ │ Server │ │ │ Mozilla Push │ │ │
│ │ (sub │──►│ (store │ │──►│ Service │──►│ (receives push & │
│ │ script) │ │ & push)│ │ │ │ │ shows notification)│
│ └──────────┘ └─────────┘ │ │ │ │ │
│ │ │ │ │ │
└──────────────────────────────┘ └────────────────────────┘ └────────────────────────┘
-
Your App (Client) — Subscribes to push, gets a unique
subscriptionobject containing an endpoint URL and encryption keys. - Your Server (Backend) — Stores subscriptions. When it wants to send a notification, it sends an encrypted payload to step 3.
- Push Service (Browser Vendor) — Google's FCM (Chrome), Mozilla's Push Service (Firefox), Apple's APNs (Safari). These maintain persistent connections to browsers and deliver the push message.
-
Service Worker — Receives the
pushevent, decrypts the payload, and callsshowNotification().
The Push Flow (Step by Step)
-
Client subscribes:
- Request
Notification.requestPermission()→ user grants permission - Call
reg.pushManager.subscribe()with your VAPID public key - Receive a
PushSubscriptionobject (contains endpoint URL + keys) - Send this subscription to your server for storage
- Request
-
Server sends a push:
- Server takes the stored subscription endpoint + keys
- Encrypts the message payload using the Web Push protocol
- Sends an HTTP POST to the push service endpoint
- The push service queues and delivers it to the browser
-
Service Worker receives:
-
pushevent fires in the SW (even if all tabs are closed) - SW reads the payload:
event.data.json() - SW calls
self.registration.showNotification(title, options)
-
-
User interacts:
-
notificationclickevent fires in the SW - SW can open a URL, focus an existing tab, or dismiss
-
VAPID Keys What and Why
VAPID (Voluntary Application Server Identification) keys are a public/private key pair:
- Public key — given to the browser during subscription (identifies your server)
- Private key — kept on your server, used to sign push messages
Purpose: The push service verifies that the push message is coming from the same server that created the subscription. Prevents anyone else from sending pushes to your users.
Key Constraints
-
userVisibleOnly: true— The spec requires that every push notification results in a visible notification to the user. You CANNOT use push for silent background data sync (in most browsers). - Subscription can expire — The push service may invalidate subscriptions (returns HTTP 410 Gone). Your server must handle this and remove stale subscriptions.
- iOS requires installation — Push notifications on iOS Safari only work if the PWA is added to the home screen (iOS 16.4+).
Notification Options (Interview Reference)
| Option | Purpose |
|---|---|
body |
Notification text content |
icon |
Small icon (appears alongside text) |
badge |
Monochrome icon for status bar (Android) |
image |
Large hero image in the notification |
tag |
Groups notifications — same tag replaces existing notification |
renotify |
Vibrate even if replacing an existing notification (same tag) |
requireInteraction |
Notification stays until user interacts (doesn't auto-dismiss) |
actions |
Array of buttons shown in the notification (max 2-3) |
data |
Arbitrary data passed to notificationclick handler |
vibrate |
Array of vibration pattern durations (ms) |
Web App Manifest and Installability
What is the Manifest?
The Web App Manifest is a JSON file (manifest.json) linked from your HTML that tells the browser:
- This site can be installed as an app
- Here is its name, icon, theme color, and display mode
- Here is the start URL when launched from the home screen
Essential Manifest Fields
| Field | Purpose | Required for Install? |
|---|---|---|
name |
Full app name (splash screen, app listing) | ✅ (or short_name) |
short_name |
Abbreviated name (home screen icon label) | ✅ (or name) |
start_url |
URL opened when app is launched | ✅ |
display |
Display mode (standalone, fullscreen, minimal-ui, browser) |
✅ (must not be browser) |
icons |
Array of icon objects with src, sizes, type
|
✅ (192×192 + 512×512 minimum) |
theme_color |
Color of the title bar / status bar | Recommended |
background_color |
Splash screen background color | Recommended |
scope |
URL scope the app controls | Defaults to manifest directory |
description |
App description | Optional |
screenshots |
App screenshots (used in richer install UI) | Optional |
shortcuts |
Quick actions from long-press on app icon | Optional |
share_target |
Makes app a share target (can receive shared content) | Optional |
Display Modes What They Mean
| Mode | Behavior | Use When |
|---|---|---|
fullscreen |
Entire screen, no browser UI at all | Games, immersive experiences |
standalone |
Own window, no URL bar (looks like a native app) | Most PWAs — this is the default choice |
minimal-ui |
Like standalone but with minimal nav controls (back/reload) | Apps where users might need browser controls |
browser |
Normal browser tab | Not installable — defeats the purpose |
Installability Criteria (Chrome)
For Chrome to show the install prompt, your app must have:
- ✅ HTTPS (or localhost for development)
- ✅ Web App Manifest with:
name/short_name,icons(192×192 + 512×512),start_url,display≠browser - ✅ Registered Service Worker with a
fetchevent handler - ✅ Not already installed
The Install Prompt Flow
The browser fires a beforeinstallprompt event when installability criteria are met. You can:
- Capture the event and prevent the default mini-infobar
- Store the event reference
- Show your own custom install button at the right moment
- When user clicks → call
deferredPrompt.prompt() - Check
userChoiceto know if they accepted or dismissed
Interview insight: You CANNOT trigger the install prompt on your own — it's gated by the browser. You can only intercept the browser's prompt and show it at a better time.
Detecting Installed State
// Check if running as installed PWA
window.matchMedia('(display-mode: standalone)').matches // true if installed
window.navigator.standalone === true // iOS Safari
You can also detect in CSS:
@media (display-mode: standalone) {
.install-button { display: none; }
}
Cache Versioning and Update Strategies
Why Cache Versioning Matters
Without versioning, cached assets can become permanently stale. The user keeps getting old CSS/JS even after you deploy new versions.
The Versioning Pattern
-
Name caches with a version:
shell-v2.1.0,static-v2.1.0 -
During
install: Open the new versioned cache, precache new assets -
During
activate: Delete all caches that aren't in the current version's cache set
This ensures that when a new SW activates, it cleans up all caches from previous versions.
Cache Size Management
Without limits, caches grow forever. Two strategies:
| Strategy | How | When |
|---|---|---|
Entry limit (maxEntries) |
Keep only the last N items (FIFO eviction) | Image caches, API caches |
Time limit (maxAgeSeconds) |
Delete entries older than N seconds | API responses, dynamic content |
TTL Based Cache Expiration Pattern
The Cache API doesn't natively support expiration timestamps. The common workaround:
- When caching a response, add a custom header (
sw-cache-timestamp) with the current time - When reading from cache, check the timestamp — if older than the max age, delete and return
null - Fetch fresh from network on expiry
The Two Layer Caching Problem (Interview Gold)
Browsers have two caching layers: the HTTP cache (controlled by
Cache-Control,ETagheaders) and the Service Worker Cache API. If you're not careful, the SW might fetch from the HTTP cache (getting stale content) and store that in the Cache API — double stale.Solution: When fetching for cache updates in the SW, use
cache: 'no-cache'orcache: 'reload'in the fetch options to bypass the HTTP cache. Or rely on hashed filenames (app.a1b2c3.js) where each version has a unique URL, making HTTP cache staleness irrelevant.
Real World Architecture: Offline Capable App
System Design: Offline First Chat App
┌─────────────────────────────────────────────────────────────────┐
│ Chat App PWA │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ UI Layer │ │
│ │ ┌─────────┐ ┌────────────┐ ┌──────────────────────┐ │ │
│ │ │ Chat │ │ Contact │ │ Settings │ │ │
│ │ │ View │ │ List │ │ View │ │ │
│ │ └────┬────┘ └─────┬──────┘ └──────────┬───────────┘ │ │
│ └───────┼──────────────┼───────────────────┼───────────────┘ │
│ │ │ │ │
│ ┌───────▼──────────────▼───────────────────▼───────────────┐ │
│ │ Data Access Layer │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ IndexedDB │ │ │
│ │ │ ┌──────────┐ ┌─────────┐ ┌──────────┐ │ │ │
│ │ │ │ messages │ │ contacts│ │ outbox │ │ │ │
│ │ │ │ │ │ │ │ (pending │ │ │ │
│ │ │ │ │ │ │ │ sends) │ │ │ │
│ │ │ └──────────┘ └─────────┘ └──────────┘ │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └──────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼───────────────────────────────────┐ │
│ │ Service Worker │ │
│ │ ┌───────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ │ Cache API │ │ Background │ │ Push │ │ │
│ │ │ (App Shell + │ │ Sync │ │ Handler │ │ │
│ │ │ API cache) │ │ │ │ │ │ │
│ │ └───────────────┘ └──────────────┘ └─────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────┬──────────────────────────────┘
│
┌──────────▼──────────┐
│ WebSocket / REST │
│ Server │
└─────────────────────┘
Data Flow: Sending a Message Offline
1. User types message & hits Send
│
2. ──► Write to IndexedDB "messages" store (status: 'pending')
│
3. ──► Update UI immediately (show message with '🕐 sending...' indicator)
│
4. ──► Is online?
│ ├── YES ──► POST to /api/messages
│ │ ├── Success ──► Update status to 'sent' in IndexedDB
│ │ │ ──► Update UI indicator to '✓ sent'
│ │ └── Failure ──► Add to outbox, register background sync
│ │
│ └── NO ──► Add to "outbox" store in IndexedDB
│ ──► Register background sync ('send-messages')
│ ──► User can now close the tab safely
│
5. [Later, when connectivity returns]
──► Service Worker 'sync' event fires
──► Read pending messages from outbox
──► POST each to /api/messages (in order)
──► On success: remove from outbox, update message status
──► On failure: re-throw error (browser will retry with backoff)
Architecture Decisions Explained
| Decision | Why |
|---|---|
| IndexedDB for messages (not Cache API) | Messages are structured data, need indexed queries (by conversation, date). Cache API is for HTTP responses. |
| Separate "outbox" store | Cleanly separates "data" from "pending mutations." The outbox is a FIFO queue processed in order. |
| Optimistic UI | User sees the message immediately — no waiting for network. Status indicator shows sync state. |
| Background Sync | Guarantees delivery even if user closes the tab. Falls back to online event for non-Chrome. |
| WebSocket for real-time + REST for sync | WebSocket for live incoming messages; REST for offline queue processing (more reliable for retries). |
Testing and Debugging PWAs
Chrome DevTools Your Best Friend
| DevTools Panel | What to Check |
|---|---|
| Application → Service Workers | Registration status, update state, "Skip waiting" button, "Update on reload" checkbox |
| Application → Cache Storage | Inspect cached resources per cache name, verify cache keys |
| Application → IndexedDB | Browse stored data, verify sync queue |
| Application → Manifest | Validate manifest fields, check installability warnings |
| Application → Storage | View quota usage, "Clear site data" button |
| Network → Offline checkbox | Simulate offline mode |
| Network → Throttling | Simulate slow 3G, fast 3G |
| Lighthouse → PWA audit | Comprehensive installability and offline checklist |
Testing Checklist (Interview Reference)
| Test | What You're Verifying |
|---|---|
| First load → app works | SW registers, precaching succeeds |
| Repeat load → instant | App shell served from cache |
| Toggle offline → app shell loads | Cache-first strategy working |
| Offline → cached data visible | IndexedDB/Cache API returning stored data |
| Offline form submit → queued | Data saved to IndexedDB outbox |
| Back online → queued actions sync | Background Sync or online listener fires |
| New deployment → update prompt | SW update lifecycle working correctly |
| Install from browser | Manifest valid, install prompt appears |
| Push notification → received and clickable | Push subscription + SW push handler working |
| Cache doesn't grow unbounded | Expiration/trimming policies active |
| HTTPS on all resources | Required for SW registration |
| Lighthouse PWA audit → 100 | Comprehensive validation |
Debugging Tips
- "Update on reload" checkbox in DevTools forces a new SW install on every page reload — essential during development.
- "Bypass for network" checkbox makes the SW pass all fetches straight to the network — useful for debugging without disabling SW entirely.
- Use
console.loginside the SW and check the Service Worker console (not the page console) — click the SW file link in Application → Service Workers. - To test the full update flow, uncheck "Update on reload" and test naturally.
Trade offs, Gotchas and Platform Limitations
Common Pitfalls (Interview Gold)
| Pitfall | What Goes Wrong | Prevention |
|---|---|---|
| Caching everything | Storage fills up, stale content everywhere | Be selective — cache app shell + critical assets, TTL for API |
| Not versioning caches | Old JS/CSS served forever after deploy | Name caches with version prefix, clean old on activate |
skipWaiting() on breaking changes |
Old page HTML + new SW = version mismatch | Use prompt-based updates for breaking changes |
| No fallback for cache miss | App shows blank screen offline | Always provide an offline.html fallback |
| Caching POST responses | Cache API only stores GET by default | Use IndexedDB for write operations |
| Ignoring opaque responses | Cross-origin no-cors responses cache with status: 0
|
Check response.type before caching, or use CacheableResponsePlugin
|
| No cache size limits | Cache and IndexedDB grow unbounded on heavy use | Always set maxEntries and maxAgeSeconds
|
| Assuming Background Sync works everywhere | Chrome/Edge only | Always provide online event fallback |
| Storing state in SW global variables | SW gets terminated/restarted — state is lost | Use IndexedDB or Cache API for persistent state |
iOS Safari Limitations (Critical for Interviews)
Safari is the most restrictive platform for PWAs:
| Limitation | Impact | Workaround |
|---|---|---|
| No Background Sync | Cannot queue and retry after tab close | Use online event listener; retry on app reopen |
| No Periodic Background Sync | Cannot refresh data in background | Refresh on app focus/visibility change |
| Push only when installed | Push notifications work only for home-screen PWAs (iOS 16.4+) | Guide users to install first |
| ~7-day cache eviction | Safari may evict SW caches after 7 days of inactivity | Request persistent storage; remind users to visit regularly |
| ~50 MB storage limit | Less generous than Chrome | Monitor storage quota; compress data |
No beforeinstallprompt |
Cannot show custom install button | Use Safari's native "Add to Home Screen" share menu |
No Web App Manifest shortcuts |
No quick actions on icon long-press | N/A — platform limitation |
Service Worker Scope Gotchas
- A SW at
/scripts/sw.jsdefaults to scope/scripts/— it only intercepts requests under/scripts/*. - To control the root scope from a subdirectory, the server must set the
Service-Worker-Allowed: /response header. - Best practice: always place
sw.jsat the root of your site.
The Double Caching Problem
HTTP Cache Layer: Browser's built-in HTTP cache (Cache-Control, ETag)
SW Cache Layer: Your Service Worker's Cache API
Problem: SW fetches a resource → HTTP cache returns stale version → SW caches that stale version
Result: Even "fresh" SW cache entries contain stale data
Solution: Use hashed filenames (app.a1b2c3.js) — every version is a unique URL
OR use fetch(request, { cache: 'no-cache' }) inside the SW
Decision Frameworks
Should I Build a PWA?
Does the app need offline access?
├── YES → Strong PWA candidate
└── NO → PWA still valuable for caching/performance
│
└── Are users on mobile with poor connectivity?
├── YES → Strong PWA candidate
└── NO → Still helps with speed & installability
│
└── Need push notifications without native app?
├── YES → PWA required
└── NO │
└── Need home screen presence without native app?
├── YES → PWA with manifest
└── NO │
└── Budget for native app?
├── NO → PWA is the answer
└── YES → Evaluate native-only features needed
Cache Strategy Quick Decision
What kind of resource?
│
├── Static asset with content hash? (app.a1b2c3.js)
│ └── Cache First
│
├── HTML pages?
│ └── Network First (with optional timeout → cache fallback)
│
├── API data needing speed + freshness?
│ └── Stale-While-Revalidate
│
├── Auth, payment, analytics?
│ └── Network Only (never cache)
│
├── Precached app shell?
│ └── Cache Only
│
└── Large media / CDN assets?
└── Cache First + expiration limits
Offline First: When Is It Worth the Complexity?
| Factor | Offline-First | Online-First (with graceful fallback) |
|---|---|---|
| Users frequently offline | ✅ Full offline-first | Possible but UX suffers |
| Collaborative/real-time data | Complex (need CRDTs/OT) | ✅ Simpler |
| Read-heavy app (news, docs) | ✅ Great fit | Cache-based approach works too |
| Write-heavy app (forms, chat) | ✅ Worth the investment | Queuing still needed |
| Single-user data | ✅ Simple conflict resolution | Not needed |
| Multi-user editable data | Complex conflict resolution | ✅ Simpler if always online |
Interview Questions and Answers
Q1: What is a Service Worker and how does it differ from a Web Worker?
A: Both run in background threads, but they serve different purposes:
- Web Worker: A general-purpose background thread for CPU-intensive tasks. Lives as long as the page that created it. Cannot intercept network requests.
- Service Worker: A specific type of worker that acts as a network proxy. It intercepts all fetch requests from the page and decides how to respond. It has its own lifecycle (install → activate → idle), persists across page visits, can run when no tabs are open (for push/sync), and has access to the Cache API.
Key difference: A Web Worker helps you compute in the background. A Service Worker helps you control the network and enable offline support.
Q2: Explain the Service Worker update flow and why skipWaiting() can be dangerous.
A: When the browser detects a byte-level change in the SW file, it triggers the update flow: download → install new SW → wait → activate. The "waiting" state exists because the old SW is still controlling open tabs. Activating the new SW would create a version mismatch — old page code with new SW behavior.
skipWaiting() bypasses the waiting phase and activates the new SW immediately. This is dangerous because an open tab with old HTML/JS is now controlled by the new SW. If the new SW changes caching behavior, API response handling, or anything the old page code depends on, the app can break silently.
Safe approach: Detect the update, show a "refresh" banner, and only skipWaiting() when the user explicitly chooses to update.
Q3: What caching strategy would you use for a social media feed?
A: Stale-While-Revalidate (SWR). The feed loads instantly from cache (even if slightly stale), while a background fetch updates the cache. Next time the user pulls up the feed, they'll see the fresh version. Optionally, the SW can postMessage() to the page when fresh data arrives, and the app can show a "New posts available" toast.
For the app shell (header, nav, footer) → Cache First.
For user auth endpoints → Network Only.
For images → Cache First with expiration (max 100 entries, 60-day max age).
Q4: How would you design an offline first form submission?
A:
- When user submits the form, save the data to IndexedDB immediately (not just in memory).
- Show a success indicator with a "pending" status (optimistic UI).
- If online → POST to server immediately. On success → update status to "synced."
- If offline → register a Background Sync event with the tag
'submit-form'. - When connectivity returns, the SW wakes up, reads pending submissions from IndexedDB, and POSTs them.
-
Fallback for Safari/Firefox: listen for the
onlineevent in the main thread and retry.
The key principle: IndexedDB is the source of truth, not the server. The server is eventually consistent with the client.
Q5: What are CRDTs and when would you use them?
A: CRDTs (Conflict-free Replicated Data Types) are data structures mathematically designed to be merged from any direction without conflicts. Unlike Last-Write-Wins (which discards changes), CRDTs preserve all concurrent updates and merge them deterministically.
Examples: G-Counter (grow-only counter), PN-Counter (increment/decrement), LWW-Register, OR-Set (observed-remove set).
Use when: Multiple users can edit the same data concurrently (like Figma, Notion, or a shared whiteboard). Libraries like Yjs and Automerge implement CRDTs for JavaScript.
For simpler single-user offline scenarios, LWW (Last Write Wins) is usually sufficient.
Q6: What's the difference between Cache API and IndexedDB?
A: They store different things:
- Cache API → Stores HTTP Request/Response pairs. Used by Service Workers to cache network responses. Think of it as a programmable HTTP cache.
- IndexedDB → Stores structured JavaScript data (objects, arrays, blobs). Used for application data like user records, messages, settings.
In an offline PWA, you'd use both: Cache API for caching the app shell and API responses (in the SW), and IndexedDB for storing and querying structured app data (in both the main thread and SW).
Q7: How do push notifications work under the hood?
A: Three servers are involved:
-
Client subscribes via
pushManager.subscribe()→ gets a subscription object (endpoint URL + encryption keys) - Your server stores the subscription. To send a push, it encrypts the payload and POSTs to the push service endpoint (using the web-push protocol with VAPID keys for authentication)
- Browser vendor's push service (FCM for Chrome, Mozilla for Firefox, APNs for Safari) maintains a persistent connection to the browser and delivers the message
-
Service Worker receives the
pushevent, decrypts the payload, and shows a notification viashowNotification()
Key constraint: Every push must result in a visible notification (userVisibleOnly: true). You cannot use push for silent data sync.
Q8: What are the installability requirements for a PWA?
A: In Chrome, four requirements:
- HTTPS (or localhost)
-
Web App Manifest with
name/short_name,icons(192px + 512px),start_url,displayset tostandalone/fullscreen/minimal-ui -
Registered Service Worker with a
fetchevent handler - Not already installed
Note: Safari doesn't support beforeinstallprompt — users must use the "Add to Home Screen" option from the share menu.
Q9: How would you handle the "user on a plane with no connectivity" scenario for a news app?
A: Design for full offline reading:
- App Shell → precached at SW install (nav, footer, skeleton screens)
- Articles → when user reads articles online, cache them in IndexedDB (full content, images)
- SWR for feed → the feed API response is cached. Offline users see the last-synced feed.
- Image caching → Cache First with expiration (max 200 entries, 30 days) for article images
- Offline indicator → show a subtle banner: "You're offline — showing saved articles"
- Periodic sync (if supported) → refresh the feed in the background every 12 hours
-
Persistent storage → call
navigator.storage.persist()to prevent eviction on engaged users
Q10: How do you prevent cache from growing unbounded?
A: Three approaches:
- Entry limit — Keep max N items per cache (e.g., 100 images). Evict oldest on overflow (FIFO).
- TTL (Time-to-Live) — Add a timestamp header when caching. On read, check age and delete expired entries.
- Version cleanup — On SW activate, delete all caches not in the current version's allow-list.
In production, consider using libraries like Workbox that provide built-in ExpirationPlugin handling all three automatically (maxEntries, maxAgeSeconds, purgeOnQuotaError).
Final Recommendations
Production Checklist
Start with the App Shell — Cache the minimal UI shell for instant repeat loads.
Route-based strategy — Different cache strategies for different request types (static → Cache First, API → SWR, HTML → Network First, auth → Network Only).
Always provide offline fallback — Even if it's just an
offline.htmlpage saying "You're offline."Limit cache size — Use
maxEntriesandmaxAgeSeconds. Caches grow silently until quota is hit.Version your caches — Clean up old caches on
activateevent. Old caches are dead weight.Use prompt-based updates — Don't auto-
skipWaiting()for major updates; show a refresh banner.Request persistent storage — Call
navigator.storage.persist()for installed PWAs.Handle iOS quirks — Provide fallbacks for Background Sync and understand Safari's 7-day eviction policy.
Monitor with Lighthouse — Run PWA audits in CI/CD to catch regressions.
Background Sync as progressive enhancement — Always queue to IndexedDB first; Background Sync is the optimization, not the requirement.
Test offline aggressively — DevTools offline mode, kill network at various states, test slow 3G, test SW update flow.
The One Sentence Summary
A PWA is a web app that works offline (Service Worker + Cache API), feels native (App Shell + Manifest), and stays connected (Background Sync + Push) — all while being a normal website that progressively enhances based on browser capabilities.
Top comments (0)