The 50 Progressive Web Apps questions interviewers actually ask, with direct answers, real service worker and manifest snippets, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
50 questions with answersKey Takeaways
A Progressive Web App (PWA) is a web app built with standard web technology, HTML, CSS, and JavaScript, that behaves more like an installed app. It can be added to the home screen, launched in its own window without browser chrome, work offline or on a flaky network, and reach device capabilities like push notifications and the camera, all from one codebase that also runs as a normal website. The official Chrome documentation on web.dev frames a PWA as capable, installable, and reliable, and those three words are a clean summary of what the term means. Two pieces make it real: a service worker, a background script that sits between the app and the network and can serve cached responses, and a web app manifest, a small JSON file that tells the browser the app's name, icons, and how to display it so it can be installed. In interviews, PWA questions probe how you reason about trade-offs: when to cache-first versus network-first, why a service worker only runs over HTTPS, how a new service worker takes over from an old one, and where a PWA still falls short of native. This page collects the 50 questions that come up most, each with a direct answer plus real snippets. Pair this bank with our AI interview preparation guides for the live-interview format, since the first frontend round is increasingly an AI-driven technical screen.
Watch: Progressive Web Apps (PWAs): New features & APIs
Video: Progressive Web Apps (PWAs): New features & APIs (Chrome for Developers, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Progressive Web Apps certificate.
The fundamentals every entry-level round checks: what a PWA is, service workers, the manifest, caching basics, and the offline idea underneath. If any answer here surprises you, that's your study list.
A Progressive Web App is a website built with normal web tech, HTML, CSS, and JavaScript, that can install to the home screen, run offline, and use device features like push notifications, all from one codebase. It's still a web app you reach by URL, but it behaves more like an installed app.
The three words people use are capable, installable, and reliable. Two pieces make that real: a service worker for offline and background work, and a web app manifest that describes the app so the browser can install it. Everything else is enhancement on top of a regular site.
Key point: Lead with 'a website that can install and work offline, from one codebase'. Interviewers open with this to hear whether you get the concept or just recite a feature list.
Watch a deeper explanation
Video: PWA Tutorial for Beginners #1 - What Are PWA's? (Net Ninja, YouTube)
Over a regular website, a PWA adds installability, offline support, and re-engagement through push, so users can keep it on their home screen and it survives a bad network. Over a native app, it keeps one codebase, ships updates instantly without an app store review, and is reachable by a plain link.
The honest framing is trade-offs. A PWA gives reach and update speed; a native app gives deeper platform integration and full device APIs, especially on iOS where web APIs lag. Teams pick a PWA when reach, one codebase, and fast iteration matter more than the last mile of native integration.
Key point: The it as a trade-off, not a winner. 'One codebase and instant updates versus deeper native integration' is the balanced answer this checks for.
A service worker is a JavaScript file the browser runs in the background, on its own thread, separate from the page. It sits between your app and the network as a programmable proxy: it can intercept requests, serve cached responses, and keep working even when the page is closed.
That's what makes offline, precaching, and push notifications possible. It has no DOM access and only runs over HTTPS (or localhost). You register it once from the page, and from then on it controls the pages in its scope.
// register a service worker from the page
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then((reg) => console.log('registered', reg.scope))
.catch((err) => console.error('failed', err));
}Key point: Call it 'a background script that acts as a network proxy'. That one phrase covers offline, caching, and push in a single idea, which is exactly what the question wants.
Watch a deeper explanation
Video: PWA Tutorial for Beginners #6 - Intro to Service Workers (Net Ninja, YouTube)
The web app manifest is a small JSON file that describes the app to the browser so it can be installed. It lists the app name, icons in several sizes, the start URL, a theme color, and a display mode that controls whether it opens in its own window or a browser tab.
You link it from the HTML head, and the browser reads it to build the install prompt and the home-screen icon. Without a valid manifest (and a service worker), the browser won't offer to install the app.
{
"name": "My PWA",
"short_name": "PWA",
"start_url": "/",
"display": "standalone",
"theme_color": "#102713",
"background_color": "#ffffff",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}Key point: List the load-bearing fields: name, icons, start_url, display. Knowing that install needs both a manifest and a service worker is the follow-up they'll push on.
You call navigator.serviceWorker.register with the path to the worker file, usually after checking the API exists. Registration is where the browser downloads the file and kicks off the install lifecycle.
Scope is the set of pages a worker controls, and it defaults to the folder the worker file lives in. A worker at /sw.js controls the whole origin; one at /app/sw.js controls only /app/. That's why the file usually sits at the root: to control the entire site.
// scope defaults to the worker's directory
navigator.serviceWorker.register('/sw.js', { scope: '/' });
// a worker at /app/sw.js can only control pages under /app/Key point: Mention that a root-level worker controls the whole origin because scope follows the file's directory. That detail shows you've placed a worker deliberately, not by copy-paste.
The three you name most are install, activate, and fetch. install fires once when a new worker is downloaded, and it's where you open a cache and precache the app shell. activate fires after install and is where you delete stale caches from older versions. fetch fires on every network request in scope, where you decide to serve from cache or network.
Between install and activate there's a waiting stage: a new worker holds back until the old one's pages close, so two versions don't run at once. You skip that wait with skipWaiting when you want the update to take over right away.
The service worker lifecycle
A new worker waits until old clients close before it activates, unless you call skipWaiting() to take over immediately.
Key point: Map each event to its job: install precaches, activate cleans up old caches, fetch intercepts. Reciting the names without the jobs reads shallow.
Watch a deeper explanation
Video: Progressive Web Apps (PWAs): New features & APIs (Chrome for Developers, YouTube)
You cache in the service worker using the Cache API (Cache Storage), which stores Request and Response pairs. On install you open a named cache and add your core files; on fetch you check that cache and either return the hit or go to the network.
It's different from the browser's built-in HTTP cache because you control it in code: you decide what to store, when to update it, and what to serve offline. localStorage doesn't work here, it's synchronous and unavailable to workers, so the Cache API is the right tool.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('shell-v1').then((cache) =>
cache.addAll(['/', '/index.html', '/styles.css', '/app.js'])
)
);
});Key point: Say 'Cache API, not localStorage'. Knowing localStorage is synchronous and unavailable to workers is the precise reason the key point is.
The service worker makes it possible. On install it precaches the app shell (the HTML, CSS, and JS needed to render the UI), so on a repeat visit those files come from the cache instead of the network. When the fetch event fires and there's no connection, the worker returns the cached response.
For content that isn't cached, you serve a fallback: a cached offline page, or a cached last-known version of the data. Offline isn't automatic, you design which requests are cache-first, which fall back, and what the user sees when something genuinely isn't available.
Key point: Stress that offline is designed, not free. Naming the app shell precache plus an offline fallback shows you understand there's a deliberate strategy behind it.
Because a service worker can intercept and rewrite every request the page makes, it's a lot of control to hand to code. Requiring a secure context (HTTPS) stops an attacker on the network from injecting a malicious worker that could hijack the whole site.
The one exception is localhost, which counts as a secure context so you can develop without a certificate. In production, no HTTPS means no service worker, and therefore no offline, no precaching, and no push.
Key point: the rule maps to the worker's power over requests. 'It can intercept everything, so it must be a secure context' is the reasoning, not just 'HTTPS is required'.
The browser decides a site is installable when it has a valid manifest with the right fields and a registered service worker, served over HTTPS. On desktop and Android Chrome, it fires a beforeinstallprompt event you can capture to show your own install button, then call prompt() when the user clicks it.
iOS is different: Safari doesn't fire that event, so users install through the share sheet's Add to Home Screen. That platform gap is worth naming, because 'installable everywhere' isn't quite true.
let deferred;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferred = e; // stash it, show your own button
});
// later, on button click:
// deferred.prompt();Key point: Mention that iOS installs via the share sheet, not beforeinstallprompt. Knowing that platform difference separates people who've shipped a PWA from those who've only read the happy path.
The app shell is the minimal HTML, CSS, and JavaScript needed to render the interface: the layout, navigation, and styling, without the dynamic content. You cache the shell on install so it paints instantly on repeat visits and offline.
The content (the changing data) loads into the shell separately, usually from the network with its own caching. Splitting the two is what makes a PWA feel fast: the frame appears immediately from cache while the data streams in.
Key point: Frame it as 'cache the frame, load the content into it'. That separation is the whole point, and the key point is you make it.
No. A single-page app (SPA) is an architecture: one page that swaps content with JavaScript instead of full reloads. A PWA is a set of capabilities: installable, offline-capable, service-worker-backed. They're often combined, but they're not the same thing.
You can have an SPA that's not a PWA (no service worker, no manifest, breaks offline), and a PWA that's a multi-page app. The PWA label is about the service worker, manifest, and HTTPS, not about how many pages the app has.
Key point: Draw the line clearly: SPA is architecture, PWA is capabilities. Conflating them is a common junior mistake this question hunts for.
Responsive means the layout adapts to screen size using flexible CSS, media queries, and fluid grids. It's about how the site looks across devices. A PWA is about capabilities: install, offline, background sync, push.
A good PWA is usually responsive too, because it should feel right on the phone it's installed on. But responsiveness alone doesn't make a PWA, and being a PWA doesn't guarantee good responsive design. They solve different problems.
Key point: Keep the two axes separate: responsive is layout, PWA is capability. Answering that they're unrelated concerns that often ship together is the mature take.
Run a Lighthouse audit in Chrome DevTools. It checks the installability signals: a valid manifest with the required fields, a registered service worker, HTTPS, and whether the app responds when offline. It flags exactly what's missing.
The Application panel in DevTools is the other tool: it shows your registered service workers, the manifest the browser parsed, and the contents of your caches, so you can confirm what's actually stored and served.
Key point: Naming both Lighthouse and the DevTools Application panel shows you've actually debugged a PWA, not just built one that happened to work.
Different surfaces need different icon sizes: the home screen, the splash screen, the task switcher, and the install prompt each pick the size that fits. Providing several sizes (192 and 512 are the common baseline) lets the browser choose the right one instead of scaling a single image badly.
512x512 also feeds the splash screen the browser generates on launch. A maskable icon variant lets the platform crop it into whatever shape (circle, squircle) the OS uses, so it doesn't get an awkward border.
Key point: Mention maskable icons for adaptive shapes. It's a small detail that signals you've actually gone through the install experience on a real device.
The fetch event fires whenever a page in the worker's scope makes a network request: a page load, an image, an API call, anything. Inside the handler you call event.respondWith and return a Response, from the cache, from the network, or one you build yourself.
That's the hook that makes offline and caching work, because you get to decide how every request is answered. If you don't call respondWith, the request just goes to the network normally, so the worker only changes behavior for the requests you choose to handle.
self.addEventListener('fetch', (event) => {
// if you don't call respondWith, the browser handles it normally
event.respondWith(caches.match(event.request).then((hit) => hit || fetch(event.request)));
});Key point: Say that skipping respondWith lets the request go to the network untouched. Knowing the worker only intercepts what you handle is a detail beginners miss.
start_url is the URL the app opens to when it's launched from the home screen. It's often the root or a dedicated landing route, and you can add tracking params to tell installed launches apart from browser visits. If it's wrong, the installed app opens to the wrong place.
scope defines which URLs are considered part of the app. handle outside the scope and the browser shows a mini in-app browser bar to signal you've left the app. Setting scope to the root keeps the whole site inside the installed experience.
Key point: Distinguish start_url (where it launches) from scope (what counts as in-app). Mixing them up is common, and the in-app browser bar on out-of-scope links is a nice detail to add.
For candidates with working experience: caching strategies, the update lifecycle, background features, and the judgment questions that separate people who've shipped a service worker from those who've read about one.
The common strategies are cache-first (check cache, fall back to network), network-first (try network, fall back to cache), stale-while-revalidate (serve cache now, update it in the background), cache-only, and network-only. Each trades freshness against speed and offline resilience differently.
Match the strategy to the asset. Static, versioned files (hashed JS, CSS, images) suit cache-first because they never change under the same URL. API data that must be current suits network-first. UI assets that can be slightly stale suit stale-while-revalidate for instant loads with eventual freshness.
| Strategy | Behavior | Best for |
|---|---|---|
| Cache-first | Cache, then network on miss | Static, versioned assets |
| Network-first | Network, then cache on failure | Fresh API data, HTML |
| Stale-while-revalidate | Cache now, refresh in background | Assets that tolerate slight staleness |
| Network-only | Always network, no cache | Non-cacheable requests, analytics |
Key point: Don't just define the strategies, match each to an asset type. 'Cache-first for hashed assets, network-first for API data' is the answer that proves you've made these calls.
Watch a deeper explanation
Video: Progressive Web Apps in 100 Seconds // Build a PWA from Scratch (Fireship, YouTube)
In the fetch event you check the cache with caches.match. If there's a hit, return it. If not, fetch from the network, and optionally put a copy into the cache before returning it so the next request is a hit.
You clone the response before caching, because a Response body is a stream that can only be read once: one copy goes to the cache, one goes back to the page. Forgetting the clone is a classic bug where the page gets an empty body.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((hit) => {
if (hit) return hit;
return fetch(event.request).then((res) => {
const copy = res.clone(); // stream reads once
caches.open('runtime').then((c) => c.put(event.request, copy));
return res;
});
})
);
});Key point: Volunteer the response.clone() detail and why. Knowing a Response body is a one-read stream is exactly the gotcha this question is testing.
The browser re-checks the worker file on navigation (and periodically). If the bytes differ at all, it treats it as a new version and runs install for the new worker. That new worker enters the waiting state and only takes control once every tab controlled by the old worker has closed.
The trap is forcing skipWaiting blindly, which can swap the worker mid-session and mismatch cached assets against a running page. The good pattern is to install quietly, detect the waiting worker, and prompt the user to reload to get the update, so the switch happens at a safe moment they chose.
How a service worker update rolls out
The safe pattern: install silently, then prompt the user to reload so a fresh version doesn't swap under them mid-session.
Key point: Explain the waiting-then-prompt pattern rather than just calling skipWaiting. Understanding why an in practice swap is risky is the production signal here.
The each cache with a version (shell-v2, runtime-v2). When you ship a new worker, its install precaches into the new-named cache, and its activate event deletes any cache whose name isn't in your current allowlist. That's how old assets get purged instead of accumulating forever.
Doing cleanup in activate matters because that's the point where the new worker is taking over and it's safe to remove what the old one relied on. Skip it and users carry stale caches and bloated storage across versions.
const CURRENT = ['shell-v2', 'runtime-v2'];
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => !CURRENT.includes(k)).map((k) => caches.delete(k)))
)
);
});Key point: Say that cleanup belongs in the activate event and why. Deleting old caches at the wrong time (during install) can pull assets the old worker still needs.
Two APIs combine. The Push API lets your server send a message to the browser's push service even when the app is closed; the Notifications API displays it. You ask permission, subscribe the user via PushManager to get an endpoint, and send that subscription to your server.
When your server pushes, the service worker wakes on a push event and calls showNotification. The user doesn't need the tab open, that's the whole point. Permission is a real friction point, so you ask for it in context, not on first load, or users just deny it.
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(data.title || 'Update', {
body: data.body,
icon: '/icon-192.png',
})
);
});Key point: Split it clearly: Push API delivers, Notifications API displays, and the service worker handles the push event. permission timing matters.
Background Sync lets a PWA defer an action until the device has a stable connection. The classic case is a user submitting something offline: you register a sync tag, and the browser fires a sync event in the service worker once connectivity returns, where you retry the request.
It solves the lost-action problem: without it, an offline submit either fails or you hand-roll retry logic in the page, which dies when the tab closes. Support is uneven across browsers, so you treat it as an enhancement with a fallback, not a guarantee.
Key point: The offline-submit use case and that browser support is uneven. Presenting it as progressive enhancement, not a promise, is the honest, senior framing.
They store different things. The Cache API stores Request and Response pairs, it's for caching network assets and API responses to serve offline. IndexedDB is a structured, queryable database in the browser for application data: records you create, read, update, and query offline.
So you cache the app shell and asset responses with the Cache API, and store user-created data (a draft, a list, queued actions) in IndexedDB. Reaching for localStorage instead is the mistake: it's synchronous, size-limited, string-only, and unavailable in the service worker.
| Need | Cache API | IndexedDB |
|---|---|---|
| Store network responses | Yes | Not its job |
| Store structured app data | No | Yes, with indexes and queries |
| Available in service worker | Yes | Yes |
| Good for offline drafts/queues | No | Yes |
Key point: Draw the line: Cache API for responses, IndexedDB for structured data. Adding 'not localStorage, it's sync and worker-unavailable' is the detail that lands.
In the fetch handler you try the network first. On success you optionally update the cache and return the response. On failure (offline or an error), you fall back to caches.match to serve the last cached copy.
This suits HTML and API data where fresh matters but offline should still show something. You often add a timeout so a slow network doesn't hang the user forever before falling back to cache.
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((res) => {
const copy = res.clone();
caches.open('runtime').then((c) => c.put(event.request, copy));
return res;
})
.catch(() => caches.match(event.request))
);
});Key point: Mention adding a network timeout before falling back. Without it, a slow connection is worse than being offline, and interviewers like to probe that edge.
Almost always scope. A worker only controls pages at or below its own directory, so /app/sw.js can't control the site root. Move the file to the root, or set the Service-Worker-Allowed header to widen the scope beyond the file's location.
The other common cause is the first load: a page that loaded before the worker registered isn't controlled until it reloads (or you call clients.claim() in activate to take over open pages immediately). So a fresh registration seems to do nothing until the next navigation.
Key point: Split the answer into scope and first-load control. Knowing clients.claim() takes over already-open pages is the detail that shows real debugging experience.
Workbox is a set of libraries from the Chrome team that wrap the common service worker patterns: routing, the standard caching strategies, precaching with revision hashes, expiration, and cleanup. You declare routes and strategies instead of hand-writing fetch handlers and cache versioning.
The value is that hand-rolled service workers are easy to get subtly wrong: cache versioning, response cloning, expiration, precache manifests. Workbox handles those correctly and integrates with build tools to generate a precache list automatically. For anything beyond a demo, most teams reach for it.
Key point: Frame it as 'the hard parts done correctly': versioning, expiration, precache manifests. Being able to hand-write a worker but choosing Workbox for production is the balanced answer.
Precache an offline.html during install. Then in the fetch handler, when a navigation request fails (the user is offline and the page isn't cached), return the cached offline page instead of letting the browser show its default error.
You scope this to navigation requests (event.request.mode === 'handle') so you don't return the offline HTML for a failed image or script. It's a small touch that makes offline feel intentional rather than broken.
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => caches.match('/offline.html'))
);
}
});Key point: Scope the fallback to navigation requests. Returning the offline HTML for a failed image is a bug interviewers like to catch.
display controls how the installed app launches. fullscreen uses the entire screen with no system UI (games, media). standalone gives its own window without browser chrome but keeps the system status bar, and it's the most common for app-like PWAs. minimal-ui keeps a minimal set of navigation controls. browser opens it as a normal tab, effectively not app-like.
The browser falls back down the list if a mode isn't supported. Most product PWAs pick standalone: it feels native without hiding the OS status bar that fullscreen removes.
| display | Browser UI | Typical use |
|---|---|---|
| fullscreen | None at all | Games, immersive media |
| standalone | No browser chrome, keeps status bar | Most app-like PWAs |
| minimal-ui | Minimal navigation controls | Content apps wanting back/reload |
| browser | Full browser tab | Effectively a normal site |
Key point: Know that standalone is the common default and why (native feel without hiding the status bar). Reciting all four without a recommendation reads like memorization.
Storage isn't unlimited. Browsers give an origin a quota based on available disk (often a percentage of free space), shared across the Cache API, IndexedDB, and other storage. You can check it with navigator.storage.estimate to see usage and quota.
When space runs low, the browser can evict your origin's storage, and by default it evicts best-effort data without warning. If your PWA truly needs its offline data to survive, you request persistent storage with navigator.storage.persist, which asks the browser not to evict it.
const { usage, quota } = await navigator.storage.estimate();
// ask the browser to keep this data across pressure
const persisted = await navigator.storage.persist();Key point: Mention persistent storage and eviction. Assuming the cache lives forever is the mistake, and knowing storage.persist() exists is the detail that stands out.
iOS Safari is the big one. It supports service workers and offline, but historically lagged on Web Push (added later than others), doesn't fire beforeinstallprompt, installs only via the share sheet, and limits background features. So install UX and push need iOS-specific handling.
More broadly, Background Sync and some device APIs are Chromium-mostly. The right approach is feature detection and progressive enhancement: check whether an API exists, use it when it does, and give a working fallback when it doesn't, rather than assuming one browser's behavior everywhere.
Key point: Lead with iOS specifics (share-sheet install, later push support) and 'feature-detect, don't assume'. That's the practical cross-browser literacy this question checks.
skipWaiting tells a newly installed worker to activate immediately instead of waiting for old tabs to close. clients.claim, called in activate, makes the active worker take control of already-open pages that weren't controlled before, including the page that just registered it.
Together they force a fast takeover. The risk is doing it silently mid-session: the new worker's caches may not match the page the user is looking at, which can break assets. The safe pattern is to install quietly, then prompt the user to reload, calling skipWaiting only when they accept.
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim()); // control open pages now
});
// skipWaiting() is usually triggered after a user reload promptKey point: Explain the pair together and the mid-session risk. Calling skipWaiting reflexively without a reload prompt is exactly the mistake this question probes.
iOS Safari doesn't fire beforeinstallprompt, so you can't show a one-tap install button the way you can on Chrome. Instead users install through the share sheet's Add to Home Screen, so the practical answer is to detect iOS Safari and show a short instruction (tap Share, then Add to Home Screen).
You also add the iOS-specific bits: an apple-touch-icon for the home-screen icon and apple-mobile-web-app-capable so it launches without Safari chrome. It's more manual than Android, and pretending install works identically everywhere is the trap.
Key point: Show you know the iOS path is manual (share sheet plus apple-touch-icon meta). Claiming install is one-tap everywhere is a red flag for anyone who's shipped to iOS.
They automate the boilerplate. Angular has a first-party service worker package that generates a precache manifest and handles versioned updates. Next.js and other React setups use plugins (often Workbox under the hood) to generate the service worker and inject the precache list at build time, so hashed assets get cached and invalidated correctly.
The value is that build integration produces the precache manifest for you, keeping it in sync with your actual output, which is tedious and error-prone by hand. You still own the strategy decisions (what's cache-first, what's network-first) and the update UX; the framework just removes the mechanical work.
Key point: Credit the framework for the precache manifest and update wiring, but stress you still own the caching strategy. That split shows you understand what's automated versus what's judgment.
advanced rounds probe architecture, performance, security, and production scars. Expect every answer here to draw a follow-up about trade-offs and failure modes.
The biggest one is precaching too much on install: pull in a giant asset list and the first visit is slower and the worker is slow to activate. Precache only the app shell and let runtime caching fill the rest on demand. Another is a fetch handler that adds latency by always awaiting the network before the cache, or by caching things that shouldn't be cached.
Also watch the double-request trap: an over-eager service worker can turn one navigation into a cache lookup plus a network fetch plus a cache write on the hot path. Keep the fetch handler lean, scope which requests it even intercepts, and measure with real navigations rather than assuming the worker is always a win.
Key point: The over-precaching and a heavy fetch handler as the real costs. Acknowledging a service worker can add latency, not just remove it, is the production signal.
A service worker can intercept and rewrite every request in scope, so a compromised one is a serious problem: it could serve altered responses to every visitor until the worker updates. HTTPS is the baseline defense, and you keep the worker file itself under tight review because it's high-privilege code.
Beyond that: be careful what you cache (never cache responses with sensitive per-user data in a shared cache), respect cache headers and auth, and remember the worker persists, so a bad deploy can pin a broken or malicious version until it's replaced. Treat the service worker like production infrastructure, because it effectively is.
Key point: Frame the worker as high-privilege code that persists across visits. 'A bad worker sticks around until replaced' is the risk the question needs you to articulate.
First, understand why: the worker or its cached HTML is being served without revalidation, so the browser never sees the new worker. The fix pattern is to make sure sw.js is served with no-cache (short or zero HTTP cache lifetime) so the browser always re-checks it, then ship a new worker that cleans old caches in activate and prompts a reload.
For users already stuck, the new worker needs to detect the stale caches by version and purge them on activate, and you may use clients.claim() plus a reload prompt to force the transition. The deeper lesson is never let the service worker file itself be cached hard, that's how sites get pinned to a broken version.
Key point: Root-cause it as a hard-cached sw.js, then fix with no-cache headers plus versioned cleanup. This is a real production scar, and interviewers ask it to hear if you've been burned.
Deep platform integration. Native apps get full access to hardware and OS features (some Bluetooth and NFC modes, tight background execution, certain sensors, richer notifications) that the web either lacks or gates behind permissions. iOS in particular limits web APIs and background work more than Android.
There's also discoverability and trust: many users still expect to find apps in a store, and some device features (contacts, certain filesystem access, background location) are limited or unavailable. The honest answer is a PWA covers most needs with far less cost, and you reach for native when a specific hard requirement (heavy background processing, a native-only API) genuinely needs it.
Key point: Give concrete gaps (background execution, some device APIs, iOS limits) rather than a vague 'less capable'. Specifics prove you've actually hit the wall.
The Core Web Vitals apply: Largest Contentful Paint for loading, Interaction to Next Paint for responsiveness, and Cumulative Layout Shift for visual stability. A cached app shell helps LCP on repeat visits by painting instantly, but the service worker can't fix a slow first visit, that's still your critical-path work.
The nuance is that INP measures main-thread responsiveness, and a service worker runs off the main thread, so it doesn't directly help INP; heavy JavaScript in the page still does. So you use the worker to make repeat loads fast and offline-resilient, and separately keep the main thread light for interaction responsiveness.
Key point: Reference INP (not FID) for responsiveness and note the worker is off-main-thread. Knowing what the service worker does and doesn't help is the precise, current answer.
Precache the small, stable, always-needed set: the app shell, core CSS and JS, the offline fallback, key icons. It's the stuff the app can't render without, so paying for it once on install is worth it. Keep the precache list tight and revisioned so updates purge cleanly.
Cache at runtime the large or unpredictable set: images, API responses, less-visited routes. You don't know in advance which the user needs, and precaching all of it bloats install and storage. Runtime caching with the right strategy (and expiration limits so it doesn't grow forever) covers those on demand.
Key point: The rule is 'precache the critical shell, runtime-cache the long tail'. Adding expiration limits on runtime caches shows you've thought about storage growth in production.
The clean approach is content hashing plus precache revisions. Build tools fingerprint asset filenames (app.a1b2c3.js), so a changed file gets a new URL and the old cached one is simply never requested again. Your precache manifest lists each file with a revision, and a new service worker precaches the changed ones and drops the stale entries.
For runtime-cached responses (APIs, images) you use expiration: a max age or max entry count so entries age out, plus network-first or stale-while-revalidate where freshness matters. The failure mode to avoid is caching un-hashed HTML or JS cache-first, that's how users get pinned to old code with no way to update.
Key point: Lead with content hashing so URLs change on edit. Warning against cache-first on un-hashed HTML is the gotcha that shows you've debugged a stale-asset incident.
The risk is client-rendered content: if the meaningful HTML only appears after JavaScript runs, crawlers that don't execute JS well may index an empty shell. The service worker doesn't change that, crawlers don't run your service worker. So the fix is server-side rendering or prerendering the important content, not relying on the client.
Otherwise a PWA is a website and indexes like one: real URLs per view, proper links, correct titles and metadata, and no content locked behind interaction. Treat the app shell as an enhancement over server-rendered HTML, and SEO stays intact.
Key point: Separate the concerns: SEO is about server-rendered content and real URLs, the service worker is irrelevant to crawlers. Recommending SSR/prerender for the shell-only risk is the answer.
Start from the data. Decide what lives locally in IndexedDB so the UI can read and write with no network, and have the app treat local state as the source of truth for the session. Writes go to local storage immediately and queue an outbound change, so the user never waits on the network to act.
Then sync: flush the queue when connectivity returns (Background Sync where supported, a manual retry loop where not), and handle conflicts explicitly, last-write-wins, per-field merge, or server-authoritative, because the same record can change in two places while offline. Naming conflict resolution as a first-class problem is what separates a real offline design from 'just cache the API'.
Designing an offline-first PWA feature
Offline-first isn't caching bolted on late. Conflict resolution is the hard part, and skipping it is where offline features quietly corrupt data.
Key point: Anchor the whole answer in conflict resolution and local-first writes. Candidates who only talk about caching responses miss that offline writes are the actual hard part.
Through messaging, because the worker has no DOM access. The page posts to the worker via the active registration, and the worker posts back to clients it looks up with clients.matchAll or the message event's source. Each side listens for 'message' events and reacts.
You use this for things like telling the page a new version is waiting (so it can show a reload prompt), or the page asking the worker to cache something on demand. For more structured request/response, a MessageChannel gives you a dedicated port per exchange instead of one shared channel.
// page -> worker
navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' });
// worker -> pages
self.addEventListener('message', (e) => {
if (e.data.type === 'SKIP_WAITING') self.skipWaiting();
});Key point: Note the worker has no DOM, so postMessage is the only bridge. MessageChannel for structured request/response matters.
On Android, a Trusted Web Activity (TWA) wraps your PWA in a thin native shell that runs it full-screen in Chrome without browser UI, and you publish that wrapper to the Play Store. Tools like Bubblewrap generate the wrapper. Ownership is verified via Digital Asset Links so the URL bar can be hidden.
It's the same PWA, same URL, same service worker, just distributed through the store for users who look there. On iOS the story is weaker: you typically wrap the PWA in a WebView-based container to submit it, with the platform's usual constraints. The point is the web app stays the source of truth; the store listing is a distribution channel.
Key point: Know TWA plus Bubblewrap and Digital Asset Links for Android, and that iOS is more constrained. This is a real 'have you shipped to a store' filter.
The capability set has grown: the File System Access API for reading and writing local files, the Web Share API to hook into the native share sheet, the Badging API for an app-icon count, Periodic Background Sync for scheduled refreshes, and Web Push for notifications. These close part of the gap with native.
The catch is uneven support, especially on iOS, and permission friction. So you feature-detect each one and enhance progressively: use the File System Access API where present, fall back to a download link where not. Treating them as bonuses over a working baseline, not requirements, is how you ship them safely.
Key point: The a few real APIs (File System Access, Web Share, Badging) and immediately pair them with feature detection. Listing APIs without the support caveat indicates naive.
Layer it. Unit-test the caching logic and message handlers in isolation. Use DevTools to simulate offline and throttled networks, and the Application panel to inspect registered workers, caches, and the parsed manifest. Lighthouse (in CI too) guards the installability and performance baseline on every build.
The parts people forget are update and offline paths: test that a new worker version cleans old caches and prompts a reload, and that offline shows your fallback rather than a broken page. Automated end-to-end tools that drive a real browser (with service workers enabled) catch the lifecycle bugs unit tests can't.
Key point: Call out testing the update and offline paths specifically, plus Lighthouse in CI. Most candidates test the happy online path and miss the lifecycle, which is where PWA bugs live.
When the core requirement is something the web can't do well: heavy sustained background processing, deep OS integration, or platform APIs that are native-only. If the product lives or dies on those, a PWA fights the platform and a native or hybrid app is the honest call.
Also when the audience is entirely iOS and depends on features iOS gates or omits, or when a store presence and native trust signals are non-negotiable for the business. A PWA shines for broad reach, one codebase, and fast iteration; when those aren't the priorities and a hard native requirement is, recommending against it is the mature answer.
Key point: Being willing to say 'don't build a PWA here' with a concrete reason is a strong production signal. Interviewers ask this to check you're not treating PWA as a hammer for every nail.
It's shifted. The classic app shell (cache an empty frame, fill it client-side) was built for client-rendered SPAs. With modern server-side rendering and streaming, the server sends meaningful HTML on the first request, so caching a blank shell can actually hurt: you'd serve an empty frame when the server could have streamed real content.
The current thinking is to cache full rendered responses with the right strategy (network-first for HTML, stale-while-revalidate where staleness is fine) and precache only the truly static assets. So the service worker still matters for offline and repeat-visit speed, but blindly precaching a client-rendered shell is dated advice when your framework streams HTML.
Key point: Show you know the app shell was an SPA-era pattern and streaming SSR changes the calculus. Reciting 'always cache the app shell' without that nuance indicates out of date.
You can't rely on local DevTools for real users, so you instrument. Report service worker lifecycle events and errors to your logging or error-tracking tool, tag requests so you can see cache-hit versus network rates, and track how many users are on the current worker version versus stuck on an old one.
The events worth watching: install and activation failures, unhandled errors inside fetch handlers (which can break the whole site for that user), and slow cache operations. Because a bad worker persists across visits, you also want a fast kill switch: a way to ship a minimal worker that unregisters or clears caches, so one bad deploy doesn't pin users to a broken version.
Key point: The kill-switch idea (ship a worker that can unregister or clear caches). Planning for a bad worker in production, not just building the happy path, is the senior instinct here.
A PWA sits between a plain website and a native app. Against a regular website, it adds installability, offline behavior, and background features. Against a native app, it trades some deep platform integration for one codebase, instant updates without an app store review, and reach through a URL. Knowing where each option fits, and saying the trade-off out loud, is itself an interview signal.
| Aspect | Regular website | PWA | Native app |
|---|---|---|---|
| Install | No install, just a URL | Installs from the browser, no store needed | Installs from an app store |
| Offline | Fails without network | Works offline via service worker cache | Works offline by design |
| Distribution | Any browser via a link | Any browser link, plus optional store listing | Store review and approval |
| Updates | Instant on reload | Instant, controlled by the service worker | User updates through the store |
| Device access | Limited web APIs | Growing web APIs, still gaps on iOS | Full platform APIs |
Prepare in layers, and trade-offs out loud is the explanation path. Most PWA rounds move from concept questions to a hands-on task (register a service worker, write a manifest, add caching) to a scenario discussion, so rehearse each stage rather than only reading answers.
The typical PWA interview flow
Earlier rounds increasingly run as AI-driven technical interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Progressive Web Apps questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works