DEV Community

ZeeshanAli-0704
ZeeshanAli-0704

Posted on

Frontend System Design: Offline Support and Progressive Web Apps (PWAs)

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

  1. What is a Progressive Web App (PWA)?
  2. Core PWA Building Blocks
  3. Service Worker Lifecycle Deep Conceptual Understanding
  4. Cache Strategies Deep Dive
  5. Offline First Architecture Patterns
  6. Client Side Storage Comparison and Mental Models
  7. App Shell Architecture
  8. Background Sync Concepts and Patterns
  9. Push Notifications How They Actually Work
  10. Web App Manifest and Installability
  11. Cache Versioning and Update Strategies
  12. Real World Architecture: Offline Capable App
  13. Testing and Debugging PWAs
  14. Trade offs, Gotchas and Platform Limitations
  15. Decision Frameworks
  16. Interview Questions and Answers
  17. 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      │
└─────────────────┘                └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

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    │
                    └────────────┘
Enter fullscreen mode Exit fullscreen mode

How These Pieces Relate (Mental Model)

Think of the architecture in layers:

  1. App Shell — The skeleton UI (header, nav, footer) that loads instantly from cache. It's the "frame" of your app, with no data content.
  2. 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."
  3. 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.
  4. 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.
  5. 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
                    └───────────────┘
Enter fullscreen mode Exit fullscreen mode

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 scope option). A SW at /sw.js controls the root '/' scope.
  • Registration should happen after page load to avoid competing for bandwidth.

2. Install

  • Triggers the install event 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 redundant state.
  • 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 activate event 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 fetch event — 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()
Enter fullscreen mode Exit fullscreen mode

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 RequestResponse 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)            │  │
│  └──────────────┘  └──────────────┘  └───────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Four Core Principles

  1. Local-first reads — Always read from local storage (IndexedDB), never from the network for initial render. The network is used for sync, not reads.

  2. 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.

  3. 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.

  4. 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) │
              └─────────────┘
Enter fullscreen mode Exit fullscreen mode

Data Flow Pattern for Offline First Writes

The critical pattern to understand (and explain in interviews):

  1. User performs an action (e.g., creates a post, sends a message)
  2. Write to IndexedDB immediately — assign a clientId (UUID), set syncStatus: 'pending'
  3. Update UI instantly — the user sees their action reflected with an optimistic indicator (e.g., a clock icon, "Sending...")
  4. Queue the mutation — add to a separate syncQueue object store in IndexedDB, containing the operation type (CREATE/UPDATE/DELETE), the data, and a timestamp
  5. If online → process the queue immediately:
    • POST/PUT/DELETE to the server
    • On success: update syncStatus to 'synced', remove from queue
    • On failure: leave in queue for retry
  6. If offline → register a Background Sync event (or just wait for online event)
  7. 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 localStorage for 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 (readonly or readwrite)
  • 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()
Enter fullscreen mode Exit fullscreen mode

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)
│  └──────────────────────────────────┘    │
└──────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Key Concepts

  1. 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.

  2. Automatic retry — If the sync event handler throws an error, the browser will automatically retry with exponential backoff. You don't manage retries yourself.

  3. Survives tab close — This is the key advantage over a simple online event listener. Background Sync fires even if the user has closed all tabs. The Service Worker wakes up to handle it.

  4. 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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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)│
│  └──────────┘   └─────────┘  │   │                        │   │                        │
│                              │   │                        │   │                        │
└──────────────────────────────┘   └────────────────────────┘   └────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  1. Your App (Client) — Subscribes to push, gets a unique subscription object containing an endpoint URL and encryption keys.
  2. Your Server (Backend) — Stores subscriptions. When it wants to send a notification, it sends an encrypted payload to step 3.
  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.
  4. Service Worker — Receives the push event, decrypts the payload, and calls showNotification().

The Push Flow (Step by Step)

  1. Client subscribes:

    • Request Notification.requestPermission() → user grants permission
    • Call reg.pushManager.subscribe() with your VAPID public key
    • Receive a PushSubscription object (contains endpoint URL + keys)
    • Send this subscription to your server for storage
  2. 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
  3. Service Worker receives:

    • push event fires in the SW (even if all tabs are closed)
    • SW reads the payload: event.data.json()
    • SW calls self.registration.showNotification(title, options)
  4. User interacts:

    • notificationclick event 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:

  1. HTTPS (or localhost for development)
  2. Web App Manifest with: name/short_name, icons (192×192 + 512×512), start_url, displaybrowser
  3. Registered Service Worker with a fetch event handler
  4. ✅ Not already installed

The Install Prompt Flow

The browser fires a beforeinstallprompt event when installability criteria are met. You can:

  1. Capture the event and prevent the default mini-infobar
  2. Store the event reference
  3. Show your own custom install button at the right moment
  4. When user clicks → call deferredPrompt.prompt()
  5. Check userChoice to 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
Enter fullscreen mode Exit fullscreen mode

You can also detect in CSS:

@media (display-mode: standalone) {
  .install-button { display: none; }
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Name caches with a version: shell-v2.1.0, static-v2.1.0
  2. During install: Open the new versioned cache, precache new assets
  3. 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:

  1. When caching a response, add a custom header (sw-cache-timestamp) with the current time
  2. When reading from cache, check the timestamp — if older than the max age, delete and return null
  3. 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, ETag headers) 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' or cache: '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            │
                        └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.log inside 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.js defaults 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.js at 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. When user submits the form, save the data to IndexedDB immediately (not just in memory).
  2. Show a success indicator with a "pending" status (optimistic UI).
  3. If online → POST to server immediately. On success → update status to "synced."
  4. If offline → register a Background Sync event with the tag 'submit-form'.
  5. When connectivity returns, the SW wakes up, reads pending submissions from IndexedDB, and POSTs them.
  6. Fallback for Safari/Firefox: listen for the online event 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:

  1. Client subscribes via pushManager.subscribe() → gets a subscription object (endpoint URL + encryption keys)
  2. 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)
  3. 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
  4. Service Worker receives the push event, decrypts the payload, and shows a notification via showNotification()

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:

  1. HTTPS (or localhost)
  2. Web App Manifest with name/short_name, icons (192px + 512px), start_url, display set to standalone/fullscreen/minimal-ui
  3. Registered Service Worker with a fetch event handler
  4. 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:

  1. App Shell → precached at SW install (nav, footer, skeleton screens)
  2. Articles → when user reads articles online, cache them in IndexedDB (full content, images)
  3. SWR for feed → the feed API response is cached. Offline users see the last-synced feed.
  4. Image caching → Cache First with expiration (max 200 entries, 30 days) for article images
  5. Offline indicator → show a subtle banner: "You're offline — showing saved articles"
  6. Periodic sync (if supported) → refresh the feed in the background every 12 hours
  7. Persistent storage → call navigator.storage.persist() to prevent eviction on engaged users

Q10: How do you prevent cache from growing unbounded?

A: Three approaches:

  1. Entry limit — Keep max N items per cache (e.g., 100 images). Evict oldest on overflow (FIFO).
  2. TTL (Time-to-Live) — Add a timestamp header when caching. On read, check age and delete expired entries.
  3. 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

  1. Start with the App Shell — Cache the minimal UI shell for instant repeat loads.

  2. Route-based strategy — Different cache strategies for different request types (static → Cache First, API → SWR, HTML → Network First, auth → Network Only).

  3. Always provide offline fallback — Even if it's just an offline.html page saying "You're offline."

  4. Limit cache size — Use maxEntries and maxAgeSeconds. Caches grow silently until quota is hit.

  5. Version your caches — Clean up old caches on activate event. Old caches are dead weight.

  6. Use prompt-based updates — Don't auto-skipWaiting() for major updates; show a refresh banner.

  7. Request persistent storage — Call navigator.storage.persist() for installed PWAs.

  8. Handle iOS quirks — Provide fallbacks for Background Sync and understand Safari's 7-day eviction policy.

  9. Monitor with Lighthouse — Run PWA audits in CI/CD to catch regressions.

  10. Background Sync as progressive enhancement — Always queue to IndexedDB first; Background Sync is the optimization, not the requirement.

  11. 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)