Chapter 44 — Progressive Web Apps
Section 1 — What a progressive web app is
A progressive web app, usually called a PWA, is a website built with web technologies that can also behave like an app when installed on a device. It starts as an ordinary website: a person opens it in a browser and uses it, without installing anything. If the site meets certain conditions, the browser may offer to install it, adding an icon to the home screen or the app list, and opening it in its own window, without the browser's address bar and tabs. That installed version can work offline, send notifications with permission, and feel more like a native app, while still built from HTML, CSS, and JavaScript, and still reachable by a web address. The word "progressive" means the experience improves gradually: a basic browser visit works, and each capability — offline use, installation, notifications — is added where the device and the browser support it, without breaking the experience where they do not.
The appeal of a PWA is that one codebase can serve both the web and an app-like experience, without building separate apps for each operating system. A small team can maintain a website and, by adding a manifest and a service worker, offer an installable version that users can keep on their home screens. That does not make a PWA identical to a native app. Native apps can access device features that web apps cannot, or can only access with permission and varying support, and app stores provide discovery and payment systems that the web does not. A PWA is a choice, not a universal replacement: it suits content, tools, and services that work well in a browser and benefit from offline access and easy installation, and it suits teams that want to reach users without the cost of maintaining several native codebases.
Understanding a PWA requires understanding its two core pieces, which this chapter covers in detail. The web app manifest is a file that describes the app: its name, its icons, its colours, and how it should display when installed. The service worker is a script that runs in the background, separate from the page, and that can intercept network requests, cache resources, and enable offline use and background features. Without a manifest, a site cannot be installed in a meaningful way. Without a service worker, it cannot work offline or use background features. Together, they turn a website into a PWA, but neither alone is enough, and neither changes the fact that the underlying product is still a website, with all the web's strengths and limits.
Finally, a PWA must still be a good website first. Installation and offline support do not rescue a site that is slow, confusing, or inaccessible in a browser. The progressive approach means the browser experience is the foundation, and app-like features are enhancements on top of it. If a site only works when installed, or only works with a service worker, it has misunderstood the model. Build a fast, accessible, responsive website, then add a manifest, then add a service worker with a sensible caching strategy, then add background features where they genuinely help. That order — web first, app-like second — is what makes a PWA progressive, rather than just a website with an install button.
Check: What two files or pieces turn a website into a progressive web app?
Section 2 — The web app manifest in plain terms
The web app manifest is a JSON file, usually called manifest.json or manifest.webmanifest, that tells the browser how your site should behave when installed. It contains the app's name, a short name for the home screen, a description, icons in several sizes, a start URL, a display mode, and colours for the theme and background. When a browser offers installation, it reads the manifest to decide what icon to show, what name to put under it, what page to open first, and whether to open it in a full app window or in a browser tab. Without a manifest, the browser has no reliable information to install from, and it will not offer a proper install experience, even if the site itself works perfectly.
Writing a manifest is straightforward, but each field deserves thought. The name should be the app's real name, and the short name should be a shorter version that fits under a home screen icon without being cut off. The start URL should be the page a person should land on when opening the installed app, usually the homepage or a dedicated app entry point, and it should work offline or degrade gracefully if it cannot. The display mode, covered later, controls whether the installed app shows browser chrome or fills the screen like an app. The theme colour colours the browser's toolbar and the app's title bar on some systems, and the background colour is shown while the app loads, so both should match the app's design and provide enough contrast for any text shown over them. These details seem small, but they are what make an installed app feel like an app rather than a bookmark.
Icons are the most demanding part of the manifest, and they are covered in the next section, but the principle is to provide several sizes, in a format browsers support, so that the icon looks sharp on phones, tablets, and desktops, and in different contexts such as the home screen, the app list, and a splash screen. A manifest with only one small icon will produce a blurry or cropped icon on some devices, which undermines the app-like feel. The manifest itself must be linked from every page that should be installable, using a link element in the page's head, so that the browser can find it no matter which page the person visits first. A manifest linked only from the homepage will not be found if the person arrives on another page.
Test the manifest by checking it in browser developer tools, which can show whether it was found, whether its fields are valid, and whether its icons load. Check also that the installed app opens at the right start URL, shows the right name and icon, and uses the intended display mode. A manifest error, such as a missing icon or an invalid start URL, can block installation silently, with no clear message to the user, so verifying the manifest is a necessary step before promising installation. A correct manifest is not the whole PWA, but it is the description that makes installation possible, and it should be written as carefully as any other user-facing text.
Check: What does the browser use the manifest for when installing a site?
Section 3 — Icons, maskable icons and splash screens
Icons are the face of an installed app, and they need more care than a single image file. Different devices and launchers display icons at different sizes, in different shapes, and with different backgrounds. Some launchers show icons as circles, some as rounded squares, and some as free-form shapes, and they may crop an icon to fit. If your icon's important detail sits at its edge, it may be cropped on some devices, leaving a partial logo that looks broken. The solution is to provide icons in several sizes, and to design them with a safe zone: keep the essential logo well inside the icon's bounds, with padding around it, so that cropping to any common shape still leaves the logo intact and recognisable.
Maskable icons address this cropping problem explicitly. A maskable icon is an icon designed to fill the whole icon area, with its background extending to the edges, and its logo centred in the safe zone, so that a launcher can apply any mask shape — circle, rounded square, or another shape — without cutting off the logo or leaving awkward transparent gaps. In the manifest, you declare which icons are maskable, alongside their sizes, so that launchers that support masking can use them correctly, while other launchers can use the standard icons. Providing both a standard icon and a maskable version is the safest approach: the standard icon for contexts that do not mask, and the maskable icon for launchers that do. Test both on real devices, because a maskable icon that looks correct in a preview tool may still be cropped unexpectedly on a particular launcher.
Splash screens are the brief screens shown while an installed app loads, and they are generated from the manifest's name, background colour, and icon on many systems. A splash screen with a matching background colour and a clear icon feels like an app starting, while a splash screen with a mismatched colour or a missing icon feels like a website loading in disguise. You do not usually design a splash screen directly; you influence it through the manifest's colours and icon, so those values must be chosen to look good together, not just individually. A dark icon on a light background colour, or vice versa, with enough contrast, will produce a splash screen that is readable during the brief loading moment.
Test icons and splash screens on real devices, not just in developer tools, because launcher behaviour varies. Install the app on a phone and a tablet, check the home screen icon, the app list icon, and the splash screen, and check them in both light and dark system themes where relevant. An icon that looks sharp on one device but blurry on another, or that is cropped on one launcher but not another, needs additional sizes or a redesigned safe zone. Icons are small, but they are the most visible part of an installed app, and they are seen every time the user looks for the app. Getting them right is worth the effort of providing several sizes and testing them where they will actually appear.
Check: Why should an icon's logo sit well inside its bounds, not at its edge?
Section 4 — Installability: what the browser checks
Installability is the set of conditions a site must meet before a browser will offer to install it, and those conditions exist to protect users from installing low-quality or misleading sites. The core conditions are: the site must be served over HTTPS, it must have a valid manifest with a name, icons, and a start URL, and it must have a service worker that can handle offline use, typically by providing a fetch handler. Some browsers also check that the site is visited enough, or that the user has engaged with it, before showing an install prompt, to avoid prompting on a first, fleeting visit. The exact checks vary by browser and change over time, so you should check current browser documentation rather than memorising a fixed list, and you should test installation in the browsers your users actually use.
HTTPS is non-negotiable for installability, as it is for service workers generally, because an installed app has more capabilities than an ordinary web page, and those capabilities must not be granted to a site whose traffic could be intercepted or altered. A site served over plain HTTP cannot register a service worker and cannot be installed, no matter how good its manifest is. That rule also means that local development needs a secure context: browsers treat local development addresses as secure for testing, so you can develop a PWA locally, but a production site must use HTTPS, with a valid certificate, for installation to work at all. If installation is not offered, checking HTTPS and the certificate is a sensible first step.
The service worker condition is often misunderstood. A browser does not require a complex offline strategy for installability; it requires that a service worker exists and can respond to requests, so that the installed app has at least a basic offline capability, such as an offline fallback page. A service worker that does nothing, or that is registered but never handles a fetch, may not satisfy the check. That is why this chapter teaches caching strategies and offline fallbacks in detail: they are not optional extras for a serious PWA, but part of what makes installation meaningful. An installed app that shows a browser error page the moment it goes offline has not delivered on the promise of installation, even if it technically passed the check.
Test installability by visiting the site in a browser, checking developer tools for manifest and service worker status, and looking for the install prompt or the install option in the browser menu. If the prompt does not appear, developer tools will usually say why, whether it is a missing icon, an invalid manifest field, or a service worker problem. Do not assume that installation works because it worked once: a manifest change, a service worker failure, or a certificate problem can break installability silently. Checking installability is part of releasing a PWA, not a one-time setup step, and it should be repeated whenever the manifest or the service worker changes.
Check: Why must a PWA be served over HTTPS to be installable?
Section 5 — Service workers: a script apart from the page
A service worker is a JavaScript file that runs separately from your web pages, in the background, and that can intercept network requests, manage caches, and handle background tasks. Unlike ordinary page scripts, a service worker does not have direct access to the page's document or to its DOM, and it does not run only while a page is open: it can be woken by events, such as a network request or a push message, even when no page is open. That separation is deliberate. It lets the service worker act as a proxy between the page and the network, deciding whether to serve a request from the cache, from the network, or from a fallback, without being tied to a single page's lifecycle. It also means service worker code must be written differently from page code: no DOM manipulation, no direct access to page variables, and communication with pages through messages where needed.
The service worker's lifecycle is distinct from a page's. It is installed, then activated, then idle until an event wakes it, and it can be updated when a new version of its file is available. Installation is when the worker typically precaches essential resources, such as the app shell, so they are available offline. Activation is when it takes control of pages, cleans up old caches, and prepares to handle requests. Between events, the browser may stop the worker to save resources, and restart it when needed, so a service worker must not assume it stays running continuously, and it must not store important state only in memory. State that must survive should be stored in persistent storage, such as a cache or a database, not in a variable that vanishes when the worker stops.
Scope is another key concept. A service worker controls only the pages under its scope, which is determined by where its file lives and how it is registered. A worker registered from the site's root can control the whole site, while one registered from a subfolder controls only that folder's pages. Registering from the wrong location is a common mistake that leaves some pages uncontrolled, with no clear error. Choose the scope deliberately, register from the location that gives the intended scope, and verify in developer tools which pages the worker controls. A worker that controls only part of the site will produce inconsistent offline behaviour, where some pages work offline and others do not, for reasons that are invisible to the user.
Finally, service workers only work in secure contexts, which means HTTPS in production, and they are asynchronous and event-driven, using promises and events rather than synchronous code. That model takes some learning if you are used to page scripts, but it is what makes service workers reliable: they do not block the page, they handle events as they arrive, and they can be updated without reloading every page immediately. The rest of this chapter builds on this foundation, covering the lifecycle, caching strategies, and background features in detail, but the core idea to carry forward is separation: a service worker is not a page script, it is a background proxy with its own lifecycle, scope, and limits.
Check: Why can a service worker not manipulate a page's DOM directly?
Section 6 — Registering a service worker safely
Registering a service worker means telling the browser to install and activate your worker file for the pages it should control. Registration is done from a page script, by checking that service workers are supported, then calling the registration method with the worker file's address. That check matters: not every browser supports service workers, and a page that assumes support will throw an error where it is missing. Wrap registration in a feature check, handle failures gracefully, and let the page work normally without a worker, because a PWA must degrade to an ordinary website where service workers are unavailable. Registration failure should be logged for the developer, not shown as an error to the user, since the user can still use the site in a browser without a worker.
Timing of registration also matters. Registering too early, before the page has loaded, can compete with the page's own loading for network and processing, and may slow the first visit. A common practice is to register after the page has loaded, when the main content is already visible, so that the worker's installation does not delay what the user came to see. That delay does not prevent the worker from working on subsequent visits, and it keeps the first visit fast. Do not block rendering or interaction on registration, and do not show a loading indicator for it, because registration is a background enhancement, not a user-facing step. The user should not notice registration happening, only its benefits later, such as offline access.
Registration returns a promise that resolves with the registration object, which tells you the worker's scope and state, and which you can use to check for updates or to communicate with the worker. Handle that promise's rejection, because registration can fail for reasons such as a non-secure context, a missing worker file, or a scope problem, and an unhandled rejection will appear as a console error and may confuse debugging. Log the failure in development, fix its cause, and ensure the page still works. In production, a registration failure should not break the site: the site should simply behave as a normal website, without offline support, until the cause is fixed and the worker registers on a later visit.
Test registration in several browsers, in a private window, and after clearing site data, to ensure it works for a first visit as well as for a returning visit. Check developer tools for the worker's status, its scope, and any errors during installation or activation. Test also what happens when registration fails, by temporarily breaking the worker file's address, and confirm that the page still loads and works. Safe registration is quiet, delayed until after load, feature-checked, and failure-tolerant. A registration that is loud, early, or brittle will harm the very first impression a PWA should be making: that of a fast, reliable website.
Check: Why is it better to register a service worker after the page has loaded?
Section 7 — The service worker lifecycle: install and activate
The service worker lifecycle has three main phases: installing, waiting, and activating, with an idle state between events. When a new worker file is first registered, the browser downloads it and runs its install event. During install, the worker typically precaches the resources the app needs to work offline, such as its main HTML, stylesheets, scripts, and icons. If precaching fails, installation fails, and the worker is discarded, which is a safety feature: a worker that cannot cache its essential resources should not take control and then fail offline. The install event must therefore complete successfully, and any precaching it does should be for resources that are truly essential and reliably available, not for every resource on the site, which would make installation slow and fragile.
After installation, a new worker does not immediately take control if an old worker is already controlling pages. It enters a waiting state, until all pages controlled by the old worker are closed, at which point it activates. That waiting protects users from a sudden change mid-session: a person using the old version should not have its resources swapped underneath them while they are working. The downside is that a user who keeps a tab open for days may never receive the update, which is why update strategies, covered later, sometimes prompt the user to refresh or use a controlled activation. By default, however, waiting is the safe behaviour, and developers should understand it before trying to bypass it, because bypassing it carelessly can break a session in progress.
Activation is when the worker takes control, cleans up old caches from previous versions, and becomes ready to handle fetch events. Cleaning up old caches during activation is important: without it, old resources accumulate, storage grows, and the app may serve stale resources from a forgotten cache. Activation should delete caches that belong to previous versions and that are no longer needed, while preserving any data, such as user content stored separately, that must survive an update. That distinction — caches for app resources, which can be replaced, versus storage for user data, which must be preserved — is crucial, and mixing them in one cache makes safe cleanup impossible. Keep them separate from the start.
Test the lifecycle by registering a worker, checking its state in developer tools, updating its file, and observing whether it waits or activates. Test with a page open, with the page closed, and with several tabs open, to see how waiting behaves. Test also a failed install, by making a precached resource unavailable, and confirm that the worker is discarded rather than taking control in a broken state. Understanding install, wait, and activate is not just theory: it explains why updates sometimes seem delayed, why old caches linger, and why a new version may not appear until tabs are closed. That understanding is the foundation for managing updates well.
Check: Why does a new service worker wait instead of activating immediately?
Section 8 — Cache Storage: the worker's pantry
Cache Storage is the storage area service workers use to store responses to network requests, so they can be served later without a network connection. Think of it as a pantry: the worker stocks it during installation with essential resources, adds to it as resources are fetched, and serves from it when the network is unavailable or when a cached response is faster. Caches are named, versioned, and separate from the browser's HTTP cache, though they share the same underlying storage limits. A worker can open a named cache, add a response to it, match a request against it, and delete it, all through an asynchronous API that returns promises. That API is available to the worker, and, in a limited form, to pages as well, but its main use is inside the worker's fetch handling.
Naming and versioning caches is essential. A cache name should include a version identifier, such as the app's version or a build identifier, so that a new version of the app creates a new cache, and the old cache can be deleted during activation without confusion. Without versioning, a worker update may serve old resources from an old cache indefinitely, because the cache was never cleaned, or it may delete a cache that a still-active worker needs. A common pattern is one cache for precached app shell resources, versioned by build, and another for runtime-cached content, such as images or data, with its own management. Keeping those separate lets you update the app shell without deleting user-facing cached content, and vice versa.
Cache Storage has limits. Browsers impose storage quotas, which vary by device, browser, and available disk space, and they may evict cached data when storage is low, especially for sites the user has not visited recently. A PWA should therefore cache thoughtfully, not indiscriminately: cache what is needed for offline use and for performance, and do not cache large resources that are rarely used offline, or that change frequently and would be stale. Provide a way to manage cache size, such as limiting the number of entries in a runtime cache, and deleting old entries when new ones are added. A cache that grows without bound will eventually be evicted wholesale, losing all its offline benefit at once, while a managed cache degrades gracefully.
Test Cache Storage in developer tools, which can show cache names, entries, and sizes, and can simulate offline mode to verify that cached resources are served. Test also what happens when storage is cleared, and ensure the app recovers by fetching from the network and repopulating its caches, rather than showing a broken state. Cache Storage is powerful, but it is not permanent, not unlimited, and not a substitute for a database for user data, which is covered later. Used as a pantry for app resources, with versioned names and managed size, it is the foundation of offline PWAs.
Check: Why should a cache name include a version identifier?
Section 9 — Cache-first: fast, but watch for staleness
Cache-first is a caching strategy where the service worker, on receiving a fetch request, first checks its cache for a matching response, and if one exists, serves it immediately, without going to the network. Only if no cached response exists does it fetch from the network, and it may then store that response in the cache for future use. The benefit is speed and offline availability: a cached resource loads instantly, even without a connection, and repeated visits feel fast because the app's resources are already on the device. Cache-first is therefore well suited to resources that change rarely, such as the app's stylesheets, scripts, icons, and fonts, where serving a slightly old version is harmless, and where speed matters more than absolute freshness.
The risk of cache-first is staleness. A resource that has changed on the server will continue to be served from the cache until the cache is updated, which may not happen until the worker updates or the cache is explicitly refreshed. For a stylesheet or a script, staleness can mean a user sees an old design or runs old code after an update, which may be confusing or may break compatibility with new data. For content that changes frequently, such as news, prices, or user data, cache-first is the wrong strategy, because it will show old content as if it were current, without any indication that it is stale. Choosing cache-first therefore requires judging how often a resource changes and how harmful serving an old version would be.
Managing staleness requires a cache update strategy alongside cache-first. One approach is to version caches by build, so that a new app version creates a new cache and the old cached resources are replaced during activation, as covered earlier. Another is to update the cache in the background after serving the cached response, a pattern covered as stale-while-revalidate, which combines cache-first speed with eventual freshness. A third is to set a sensible cache lifetime for runtime resources, and to revalidate after it expires. Whichever approach you choose, test it by deploying a change, visiting the app, and checking whether the new version appears promptly, or whether the old version lingers. A cache-first strategy that never updates is not a performance optimisation; it is a frozen app.
Use cache-first for stable app resources, not for dynamic content, and pair it with versioning or background revalidation so that updates reach users. Test it offline, to confirm that cached resources load without a network, and online after an update, to confirm that new resources replace old ones. Cache-first is the simplest strategy to understand and the easiest to get wrong for dynamic content, because its speed tempts developers to apply it everywhere. Applied to the right resources, with a clear update plan, it gives a PWA its characteristic instant loading. Applied to the wrong resources, it gives users old information presented as current, which is worse than a slow load.
Check: For which kind of resource is cache-first most suitable, and why?
Section 10 — Network-first: fresh, but needs a fallback
Network-first is the opposite strategy: the service worker first tries to fetch from the network, and only if that fails — because the device is offline, or the request times out — does it serve a cached response. The benefit is freshness: the user always sees the latest content when a connection is available, which is essential for data that changes frequently, such as news, messages, prices, or user-specific content. The cost is speed and offline dependence: every request waits for the network, so a slow connection makes the app feel slow, and a failed network request must have a cached fallback or the user sees an error. Network-first is therefore best for content where freshness matters more than instant loading, and where a stale cached copy is a reasonable fallback, not a misleading one.
A network-first strategy needs a well-designed fallback. If the network fails and no cached response exists, the worker should serve a meaningful fallback, such as an offline page for navigation requests, or a cached version of the content with a clear indication that it may be out of date, where that indication is possible. A generic browser error page, or a blank screen, is not a fallback; it is an admission that the strategy had no plan for offline use. For navigation requests, an offline fallback page that explains the situation and offers to retry is far better than an error. For data requests, a cached response, even if old, may be better than nothing, provided the interface indicates that it is cached and when it was last updated, so the user does not mistake old data for current.
Network-first also needs timeout handling. A network request on a poor connection may hang for a long time before failing, leaving the user waiting with no feedback. A worker that waits indefinitely for the network, without a timeout or a fallback, will feel broken on a slow connection, even though it is technically working. Consider a timeout after which the cached response is served, or a loading state that tells the user the app is trying to reach the network. That timeout should be generous enough not to abandon a slow but working connection, but not so long that the user gives up. Test network-first on a throttled connection, not just on a fast one and on a fully offline one, because throttled connections are where its weaknesses appear.
Use network-first for dynamic, user-specific, or frequently changing content, and pair it with a cached fallback and a clear offline indication. Test it online, offline, and on a slow connection, and check that fresh content appears promptly when online, that a sensible fallback appears when offline, and that the user can tell the difference between fresh and cached content. Network-first and cache-first are complements, not competitors: a good PWA uses each where it fits, rather than applying one strategy to every request.
Check: What should network-first serve if the network request fails?
Section 11 — Stale-while-revalidate: speed with freshness
Stale-while-revalidate is a strategy that combines the speed of cache-first with the freshness of network-first. On a request, the worker serves the cached response immediately if one exists, so the user sees content instantly, and in the background it fetches a fresh response from the network and updates the cache for the next visit. If no cached response exists, it fetches from the network as normal. The user therefore gets instant loading on repeat visits, and fresh content on the visit after an update, rather than waiting for the network on every visit or seeing stale content indefinitely. That combination makes stale-while-revalidate a good default for many resources, especially those that change occasionally but where a slightly old version is acceptable for one visit, such as articles, product listings, or app data that is not time-critical.
The trade-off is that the user may see stale content for one visit after an update, before the background refresh takes effect on the next visit. For most content, that delay is acceptable, and it is far better than blocking every visit on the network. For time-critical content, such as a price, a stock level, or a live score, even one visit of stale data may be misleading, and network-first is safer. The decision depends on how quickly the content changes and how harmful a stale view would be. A blog post that is an hour old is still useful; a ticket price that changed an hour ago may cause a purchase at the wrong price. Judge each resource by its freshness needs, not by a blanket rule, and document the choice so that future developers understand why a particular strategy was chosen for a particular resource.
Background revalidation also needs care. The worker must actually perform the network fetch and cache update after serving the cached response, even though the user has already received a response, and it must handle a failed fetch gracefully, leaving the cached response in place for the next visit rather than deleting it. It must also avoid revalidating too aggressively, such as on every request for a resource that rarely changes, which would waste bandwidth and battery. A sensible approach is to revalidate on each visit for resources that change regularly, and less often for stable resources, or to rely on cache versioning for stable app resources and stale-while-revalidate for content. Test revalidation by changing a resource on the server, visiting the app twice, and checking that the first visit shows the old version instantly and the second shows the new version, which confirms that the background update worked.
Stale-while-revalidate is often the best compromise for a PWA's content, because it gives the instant feel that makes a PWA feel app-like, without the indefinite staleness of pure cache-first. Use it where a slightly old version is tolerable, pair it with a clear indication of freshness where it matters, and reserve network-first for time-critical data and cache-first for stable app resources. A PWA that uses all three strategies, each where it fits, will feel fast, stay fresh, and degrade gracefully offline, which is the combination that makes offline and performance work together rather than against each other.
Check: What does the user see on the first visit after a resource changes under stale-while-revalidate?
Section 12 — Precaching the app shell
The app shell is the minimal set of resources needed to render the app's basic interface: its HTML shell, its stylesheets, its core scripts, and its icons, without the specific content that fills it on each visit. Precaching means storing that shell in the cache during the service worker's install event, so that it is available immediately, even offline, on the next visit. When a user opens the app offline, the shell loads from the cache, and the app can show its interface, its navigation, and an offline message or cached content, rather than a browser error page. Precaching the shell is what makes an offline PWA feel like an app that is present on the device, rather than a website that requires a connection to appear at all.
Choosing what to precache is a judgement. Precaching too little means the shell is incomplete offline, with missing styles or scripts that break the interface. Precaching too much means installation is slow, storage is wasted, and a single failed resource can fail the whole installation, as covered under the lifecycle. Precaching should therefore include only the resources that are essential to the shell and that are stable across visits: the main HTML, the core CSS and JavaScript, and the icons. Content, such as articles, images, or user data, should generally not be precached, but cached at runtime when visited, so that installation remains fast and the cache does not fill with content the user may never need offline. That distinction — shell precached, content cached on demand — keeps installation quick and offline use meaningful.
Precaching must also handle updates. When the app shell changes, a new worker version should precache the new shell in a new, versioned cache, and delete the old shell cache during activation, so that users receive the new interface without stale resources lingering. If the shell's resources are referenced by hashed filenames, where each build produces a new filename for changed files, then precaching by filename naturally picks up new versions, and old filenames can be cleaned up safely. If filenames are not hashed, versioned cache names are essential to avoid serving old shell resources after an update. Either way, test updates by deploying a shell change and checking that the new shell appears after the worker updates, without requiring the user to clear site data manually.
Test precaching by installing the worker, going offline, and opening the app: the shell should load, navigation should work, and a sensible offline state should appear where content is unavailable. Test also the install experience on a slow connection, to ensure precaching does not delay the first visit excessively, and test a failed precache, to ensure the worker fails safely rather than taking control in a broken state. A well-precached shell is the foundation of an offline PWA, and it is what turns "works offline" from a slogan into an experience the user can actually rely on.
Check: What belongs in a precached app shell, and what should be left for runtime caching?
Section 13 — Runtime caching for content and images
Runtime caching means caching resources as they are requested during use, rather than precaching them at install time. When a user visits an article, views an image, or loads a data file, the service worker can store that response in a runtime cache, so that a later visit to the same content, online or offline, can be served from the cache. Runtime caching is ideal for content that is too large or too numerous to precache, such as articles, images, and user-specific data, and for content whose value offline depends on what the user actually visited, rather than on what the developer guessed they might visit. A news app that precaches every article would be slow to install and wasteful; one that runtime-caches articles the user read can offer those articles offline with minimal cost.
Runtime caches need management, because they grow with use. Without limits, a runtime cache can fill storage with old articles and images the user no longer needs, leading to eviction or to a bloated app. A common management pattern is to limit the number of entries, or the total size, and to remove the oldest entries when new ones are added, keeping the most recently used content. Another pattern is to expire entries after a certain age, so that old content is refetched rather than served indefinitely. The right limit depends on the content: images may need a larger limit than text, and frequently changing data may need a shorter expiry than stable articles. Whatever the limit, it should be documented and tested, so that the cache does not grow silently until it causes problems.
Runtime caching strategies should match the content, as with precaching. Images, which change rarely and are expensive to fetch, often suit cache-first or stale-while-revalidate at runtime. Data that changes frequently suits network-first with a runtime cache as fallback. Navigation requests suit a special handling, covered later, with an offline fallback. Applying one runtime strategy to all content, without considering its freshness needs, will produce either stale data or slow loading, depending on the strategy chosen. Choose per resource type, and make the choice explicit in the worker's fetch handler, so that a future developer can see why images are cached one way and data another.
Test runtime caching by visiting content online, going offline, and revisiting it: it should load from the cache. Test also by visiting many items, to check that cache limits work and old entries are removed, and by updating content on the server, to check that the chosen strategy refreshes it appropriately. Runtime caching is what makes a PWA feel personal offline, because it preserves the content the user actually used, not just the shell. Managed well, it is efficient and useful; unmanaged, it becomes a storage problem that undermines the offline experience it was meant to provide.
Check: Why should runtime caches have a limit on entries or size?
Section 14 — The offline fallback page
An offline fallback page is the page a PWA shows when a navigation request cannot be fulfilled from the cache or the network, because the user is offline and the requested page was not previously cached. Instead of a browser error page, which says the site cannot be reached and offers no path forward, an offline page explains the situation in plain words, reassures the user that the app is still there, and offers useful actions: retry when back online, go to a cached page such as the homepage, or view previously cached content. That page is part of the app's interface, styled like the app, and it turns an offline failure from a dead end into a waiting room with a door back in.
Designing an offline page requires thinking about what the user can still do offline. If the app has cached content, the offline page should link to it, so the user is not stranded. If the app has a search or a saved-items feature that works offline, the offline page should mention it. If nothing is available offline except the shell, the offline page should say so honestly, rather than promising content that is not there, and it should offer to retry automatically or manually when the connection returns. The page should also be precached, so that it is always available offline, even if the user has never visited it online. An offline page that itself requires a network connection to load is not a fallback; it is a second failure.
The offline page's wording matters. It should not blame the user, with phrases such as "You are offline" as an accusation, but should state the situation neutrally: "You appear to be offline. This page is not available offline, but you can still view your saved items, or try again when you are back online." It should not use technical jargon about caches or service workers, which the user does not need to understand to act. It should also be accessible, with a clear heading, readable text, and focusable actions, because an offline user may be using a screen reader or a keyboard, and the offline page is still part of the app's accessibility.
Test the offline page by going offline and navigating to a page that was not cached, on a phone and on a desktop, and checking that the offline page appears, that its links work, and that returning online and retrying loads the requested page. Test also that the offline page itself is precached and styled correctly, and that it does not show a broken layout due to missing resources. An offline fallback page is a small addition, but it is the difference between a PWA that fails gracefully offline and one that fails with a browser error, and that difference shapes whether users trust the app to work when they need it most.
Check: What should an offline fallback page offer besides an explanation?
Section 15 — Background sync for actions taken offline
Background sync is a service worker feature that lets a PWA defer an action taken offline until the connection returns. For example, a user composes a message, submits a form, or saves a change while offline. Instead of failing, the app stores the action locally, registers a sync event with a tag, and when the browser detects connectivity, it wakes the service worker and delivers the sync event, which then sends the stored action to the server. That pattern turns offline use from read-only — viewing cached content — into read-and-write, where users can continue working offline and their work is sent when possible. It is a powerful feature, but it must be used carefully, because deferred actions have consequences that immediate actions do not.
The first care is honesty about what has happened. An action deferred by background sync has not yet reached the server, and the interface must say so, rather than pretending it succeeded. A message queued offline should be shown as "Waiting to send" or "Will send when back online", not as "Sent", because the user needs to know that the recipient has not yet received it, and that they should not assume it was delivered. Similarly, a form submission queued offline should be shown as pending, with a way to view, edit, or cancel it before it sends, where that makes sense. Pretending a deferred action succeeded is misleading, and it can cause a user to leave, believing their work is done, when it is still waiting on their device.
The second care is reliability and duplication. A sync event may be delivered more than once, or a device may go offline again during a sync, so the server must handle duplicate submissions gracefully, by recognising that a retried action is the same as the original, not a new one. The worker should also handle sync failures, by retrying later or by informing the user that the action could not be sent and needs attention, rather than silently dropping it. Stored actions must survive a browser restart, so they should be kept in persistent storage, not only in memory, and they should be cleared only after the server confirms receipt. A sync system that loses actions, duplicates them, or hides their status will damage trust more than a system that simply says "You must be online to send this".
Test background sync by going offline, performing an action, checking that it is stored and shown as pending, then going online and checking that it sends, that the interface updates to sent, and that no duplicate is created if the sync is retried. Test also what happens if the device is closed before sync completes, and if the action fails on the server. Background sync is not a substitute for a good offline interface; it is a mechanism that, paired with honest status and server-side duplicate handling, lets a PWA support real work offline, which is one of the strongest reasons to build a PWA at all.
Check: Why must a queued offline action not be shown as "Sent"?
Section 16 — Push notifications: permission first, value always
Push notifications let a PWA send messages to a user even when the app is not open, through the browser's push service and the service worker, which receives the push event and shows a notification. That capability is powerful, and it is also easy to abuse, which is why browsers require explicit permission before any push notification can be shown. Permission is requested through a prompt, and the user can grant or deny it, and can revoke it later in browser settings. A PWA that asks for push permission on first visit, before the user understands its value, will often be denied, and a denied permission is hard to recover, because the browser may not show the prompt again without the user changing settings manually. Permission must therefore be earned, not demanded.
Earning permission means explaining value before asking. Show the user what notifications they will receive, how often, and how they can control them, in the context where notifications make sense, such as after they have followed a topic, saved an item, or completed a task that notifications would update. Offer a settings screen where notification types can be toggled individually, rather than an all-or-nothing choice, so the user can receive the notifications they want without receiving those they do not. Do not ask for permission on page load, do not ask repeatedly after a denial, and do not gate essential features behind permission, because a user who denies notifications should still be able to use the app fully, without them.
Notification content also matters. A notification should be concise, relevant, and actionable: it should say what happened, why it matters to this user, and what tapping it will do, by opening the relevant page in the app. It should not be used for marketing spam, for vague engagement messages, or for content the user did not ask for, because each irrelevant notification teaches the user to ignore or disable notifications entirely. Notifications should also respect quiet hours and user preferences where the platform supports them, and they should be grouped or limited in frequency, so that a burst of activity does not produce a burst of notifications that overwhelm the user.
Test push notifications by requesting permission in context, by sending a notification, by tapping it and checking that it opens the right page, and by denying permission and checking that the app still works without it. Test also revoking permission and re-requesting it, to ensure the app handles both states gracefully. Push notifications, used sparingly and with clear value, can bring users back to a PWA at the right moment. Used aggressively, they will be disabled, and the channel will be lost. Permission first, value always, is not just good manners; it is the only sustainable strategy for push.
Check: Why should a PWA not ask for push permission on first visit?
Section 17 — How web push works without a server in the page
Web push involves several parts working together, and understanding them helps you design notifications that are reliable and private. When a user grants push permission, the browser creates a push subscription, which contains an endpoint address on the browser vendor's push service, and keys that let a server send encrypted messages to that subscription. The front end sends that subscription to your application server, which stores it. When something happens that warrants a notification, the server sends a message to the push service endpoint, which delivers it to the browser, which wakes the service worker and delivers a push event, even if no page is open. The service worker then shows a notification, using the message's data, and handles the user's tap on it. That chain — subscription, server, push service, browser, service worker, notification — is what makes push work without a page being open.
Privacy is built into this chain through encryption. The push message is encrypted so that the push service, which relays it, cannot read its content, only its destination. That encryption uses keys exchanged during subscription, and it means that sensitive content should still be handled carefully: a push message should contain only what is needed to show a useful notification, not a full private message, because notifications appear on lock screens and may be seen by others. A common pattern is to send a minimal notification, such as "You have a new message", and to fetch the full content when the user opens the app, rather than putting the full message in the push payload. That pattern respects both privacy and the lock screen, where a detailed notification might expose more than the user wants.
The front end's role in web push is subscription management, not message sending. It requests permission, creates or retrieves the subscription, sends it to the server, and handles subscription changes, such as when a subscription expires or is replaced. It must also handle the case where push is not supported, or permission is denied, by degrading gracefully to in-app notifications or to no notifications, without breaking the app. The server's role is to store subscriptions securely, to send messages only to users who have opted in for that type of notification, and to handle failed deliveries, such as expired subscriptions, by removing them rather than retrying indefinitely.
Test web push end to end, from permission to subscription to server send to notification display to tap handling, on a real device, because push behaviour varies by browser and operating system, especially on mobile. Check that a notification tap opens the correct page, that a dismissed notification does not reopen the app unexpectedly, and that unsubscribing stops notifications. Web push is a chain with many links, and a failure in any link — permission, subscription, server send, service worker handling — will break notifications silently. Testing the whole chain, not just the service worker's notification code, is what makes push reliable in practice.
Check: Why should a push payload contain only minimal information?
Section 18 — App shortcuts, badges and display modes
Beyond installation itself, a PWA can offer app-like conveniences through its manifest and through platform features. App shortcuts, declared in the manifest, let a user long-press an app icon and jump directly to a specific part of the app, such as "New message", "Search", or "Saved items", without opening the homepage first. Shortcuts should point to real, useful destinations that the user visits frequently, and they should be few — a handful at most — because a long list of shortcuts is harder to scan than a short one, and because each shortcut is a promise that its destination works, including offline where relevant. A shortcut to a page that fails offline, without explanation, breaks the app-like feel that shortcuts are meant to create.
Badges are a small indicator on an app icon, such as a number showing unread items, that can be set by the app through a platform API where supported. Badges help users see at a glance that something needs attention, without opening the app, but they must be kept accurate and cleared when the user has seen the items, or they become noise that users learn to ignore. A badge that shows a count that is wrong, or that never clears, is worse than no badge, because it trains the user to distrust the icon. Badges should therefore be updated when the underlying data changes, and cleared when the user views the relevant content, with a clear rule for what counts as "seen". Not all platforms support badges, so a PWA should not rely on them as the only way to signal new content; in-app indicators remain essential.
Display modes control how the installed app looks when opened, and they are declared in the manifest. The common modes are fullscreen, standalone, minimal interface, and browser, each offering a different amount of browser chrome. Standalone is the most app-like common choice: it opens in its own window without the browser's address bar and tabs, but with system controls for closing and switching. Fullscreen removes even system controls, which suits games or media but can trap users who cannot find how to exit. Minimal interface keeps a small amount of browser chrome, such as navigation controls, which can help users who need back and forward. Browser mode simply opens in a browser tab, which is not really installed behaviour. Choose the mode that fits the app's needs and the user's ability to navigate, and test it on real devices, because the same mode may look different on different systems.
Test shortcuts by long-pressing the installed icon and following each shortcut, checking that it lands on the right page and works offline where promised. Test badges by setting and clearing them and checking the icon. Test display modes by installing and opening the app, and checking that navigation, back behaviour, and exit are clear. These conveniences are small, but they are what make an installed PWA feel like an app the user chose, rather than a website they bookmarked, and they deserve the same care as the manifest and the service worker themselves.
Check: Why should app shortcuts be few and point to frequently used destinations?
Section 19 — Standalone, fullscreen and browser display
Display mode is a manifest choice with real usability consequences, and it should be chosen deliberately, not left at a default without thought. Standalone mode opens the installed app in its own window, without the browser's address bar, tabs, or bookmarks, but with the operating system's window controls. That mode feels most like a native app, and it gives the app the full screen to work with, which suits tools, readers, and services that the user opens as a destination in themselves. Standalone apps still need their own navigation, because the browser's back button and address bar are gone: the app must provide a way to go back, to go home, and to understand where the user is, through its own interface. An app that relies on browser chrome for navigation will feel broken in standalone mode, because that chrome is not there.
Fullscreen mode goes further, removing even system controls and using the entire screen, which suits immersive content such as games, video, or presentations, where any chrome would distract. Fullscreen is risky for ordinary apps, because users may not know how to exit, how to switch apps, or how to return, especially on devices where the exit gesture is not obvious. Use fullscreen only where immersion is the point, and provide a clear, visible exit control within the app, so the user is never trapped. Minimal interface mode is a middle ground: it keeps a small set of browser controls, such as back and reload, while still feeling more app-like than a browser tab. That mode can be a good compromise for content apps where users may need browser navigation, but it is less consistently supported across platforms, so test it where your users are.
Browser mode, which opens the app in an ordinary browser tab, is the fallback when installation is not used, or when a display mode is not supported. It is not a failure: a PWA in browser mode is still a website, and it should work fully there, with the browser's navigation available. Designing for browser mode first, then enhancing for standalone, ensures that the app works everywhere, rather than only when installed. That order — browser first, standalone second — mirrors the progressive principle from the first section, and it prevents the common mistake of building an app that only makes sense when installed, and that confuses users who open it in a browser.
Test each display mode you consider, by installing the app and opening it, and by opening it in a browser tab, and checking navigation, back behaviour, and exit in each. Check also that the app's own navigation works in standalone mode, that no essential control is hidden by the lack of browser chrome, and that the app's layout adapts to the full window, including notches, safe areas, and system bars on phones. Display mode is a manifest field, but its effects are felt in every interaction, and choosing it without testing is choosing blind.
Check: What navigation must an app provide itself in standalone mode, and why?
Section 20 — HTTPS, security and service worker scope
HTTPS is required for service workers and for installation, but its importance goes beyond a checkbox. A service worker is a powerful script: it can intercept every request from the pages it controls, serve cached responses, and handle background events. If an attacker could inject a malicious service worker, or alter a legitimate one in transit, they could control what the user sees, what data is sent, and what is cached, for a long time, because a service worker persists across visits. HTTPS prevents interception and alteration in transit, by encrypting traffic and authenticating the server, so that the worker the browser installs is the worker the developer wrote, not one altered by a network attacker. That protection is why browsers refuse to register service workers over plain HTTP, except on local development addresses treated as secure for testing.
Scope is the second security boundary. A service worker should control only the pages it is meant to serve, and it should be registered from a location that gives it that scope, not a broader one. A worker registered at the root controls the whole site, which is appropriate for a site-wide PWA, but a worker for a single section of a site, registered from that section's folder, should not control other sections, where it might serve wrong cached content or intercept requests it was not designed for. Scope is also a security feature: a worker cannot control pages outside its scope, so a compromised or buggy worker in one section cannot affect another section it was never meant to touch. Choose scope deliberately, and verify it in developer tools, rather than assuming registration from a convenient location gives the intended scope.
Other security practices matter too. A service worker should not cache or serve sensitive data, such as authentication tokens, payment details, or private messages, in a way that could be read by another origin or by a future version of the worker that should not have access. Sensitive requests should generally bypass the cache, or be cached only with care and with a clear expiry, and they should never be stored in a cache that persists longer than necessary. The worker's code should also be kept simple and auditable, because its power makes its bugs consequential: a worker that caches the wrong response, or that serves a cached login page to the wrong user, can cause data leaks that are hard to detect. Review worker code as security-critical code, not as a performance add-on.
Test security by checking that the site uses HTTPS with a valid certificate, that the worker's scope is correct, that sensitive requests are not cached inappropriately, and that a worker update cannot serve another user's data. Test also what happens when a worker is updated: old cached sensitive data should be cleared where appropriate, and new worker code should not inherit stale data it was not meant to see. Security in PWAs is not an extra; it is the reason HTTPS and scope exist, and it should be part of every PWA review.
Check: Why is HTTPS essential for a script as powerful as a service worker?
Section 21 — Updating a service worker without breaking users
Updating a service worker is how a PWA receives new code, new caches, and new features, but the update process is designed to be cautious, and developers must work with it, not against it. When a new worker file is available, the browser downloads it on a subsequent visit, installs it, and, by default, waits until all pages controlled by the old worker are closed before activating. That waiting protects a session in progress, but it means a user who keeps a tab open may not receive the update for a long time. A developer who expects an update to reach users immediately will be surprised, and a user who expects a new feature to appear after a deployment may not see it until they close all tabs, which they may not do for days.
There are controlled ways to speed up updates. The worker can call a method that tells it to skip waiting and activate immediately, and pages can be told to reload when a new worker activates, so the user sees the new version promptly. Those methods must be used carefully, because activating immediately while a user is mid-task can swap resources underneath them, breaking a form they are filling or a page they are reading. A safer pattern is to inform the user that a new version is available, with a non-intrusive message such as "A new version is available. Refresh to update", and let the user choose when to refresh. That pattern respects the user's session, gives them control, and still ensures updates are not delayed indefinitely, because most users will refresh when prompted at a convenient moment.
Cache versioning is part of updating safely. A new worker should use new cache names, so its resources do not mix with the old worker's, and during activation it should delete old caches that are no longer needed. If cache names are not versioned, a new worker may serve old resources from an old cache, and the user will see a mix of old and new code, which can break the app in subtle ways, such as a new script expecting a new stylesheet that was not loaded. Versioning by build, or by using hashed filenames, prevents that mix, and activation cleanup removes the old caches so storage does not grow with every update. Test updates by deploying a change, visiting with an old worker active, and checking that the update message appears, that refreshing loads the new version cleanly, and that old caches are removed.
Finally, communicate updates honestly. Do not silently swap an app underneath a user who is working, and do not force a reload without warning, unless the update is critical for security or correctness and the user has been told why. A forced reload that loses a half-filled form will be remembered as a failure, even if the new version is better. An update prompt that the user can dismiss, with the update applied on the next visit, is usually the right balance. Updating a PWA is not just a technical deployment; it is a change to a product the user may be using at that moment, and it should be handled with the same care as any other change to a live product.
Check: Why does a new service worker not activate immediately by default?
Section 22 — Cache versioning and cleaning up old caches
Cache versioning is the practice of including a version identifier in each cache name, so that each version of the app has its own caches, and old caches can be identified and deleted safely. Without versioning, a cache name such as "app-cache" is reused by every version, and a new worker may read old resources, write new resources into the same cache, and produce a mix that is neither the old version nor the new one. With versioning, a cache name such as "app-shell-v3" belongs to version three, and version four creates "app-shell-v4", leaving version three's cache intact until activation cleanup deletes it. That separation makes updates predictable: each version's resources are isolated, and cleanup is a matter of deleting caches whose names do not match the current version.
Cleanup happens during activation, when the new worker takes control. The activation handler should list all caches, delete those that belong to the app but are not in the current version's set, and leave alone caches that belong to other purposes, such as user data stored separately. Deleting too much — for example, deleting a cache that holds user content or runtime-cached articles the user still wants offline — will remove offline content the user valued, while deleting too little will let storage grow with every update, until the browser evicts caches wholesale. The activation cleanup should therefore be precise: know which cache names belong to the current version, which belong to previous versions and should be deleted, and which belong to other data and must be preserved. Document that list in the worker code, so a future developer does not delete the wrong cache by mistake.
Version identifiers should change whenever the cached resources change, not on every deployment where nothing cached changed, and not never. A build identifier that changes with each build is a safe default, because it ensures a new cache for new resources, at the cost of discarding runtime caches that might still be useful. A more nuanced approach versions the app shell cache by build, while keeping a runtime cache unversioned or versioned separately, so that user-visited content survives an app update. That nuance is worth the complexity for apps where runtime content matters offline, because discarding it on every update would make offline use unreliable. Choose the versioning scheme that matches how much you value runtime content versus shell freshness, and document it.
Test versioning and cleanup by deploying several versions in succession, checking cache names in developer tools after each, and confirming that old caches are deleted, current caches contain the right resources, and runtime content survives where it should. Test also a failed update, to ensure that old caches remain usable if the new worker fails to install, so the user is not left with no cache at all. Cache versioning and cleanup are housekeeping, but they are the housekeeping that keeps a PWA from becoming a pile of stale resources, and they deserve the same attention as the caching strategies themselves.
Check: What should activation cleanup delete, and what should it preserve?
Section 23 — Storage quotas, eviction and graceful degradation
Storage on a device is finite, and browsers manage it by giving each site a quota, which varies by device, browser, available disk space, and how often the user visits the site. When a site exceeds its quota, or when the device runs low on space, the browser may evict data, starting with sites the user has not visited recently, or with data that is marked as less important. Cache Storage and other client storage share that quota, so a PWA that caches aggressively may hit its quota sooner, and may lose its caches when eviction happens. A PWA should therefore treat storage as a limited resource to be managed, not as an unlimited pantry, and it should degrade gracefully when storage is unavailable or evicted, by fetching from the network and rebuilding caches, rather than failing.
Persistent storage is a way to ask the browser to treat a site's storage as less likely to be evicted, where the browser supports it and the user has engaged with the site. Requesting persistent storage is not a guarantee, and it should not be requested on first visit without a reason, because it asks the browser to prioritise this site over others. It is appropriate for a PWA the user has installed or uses frequently, where offline data matters, and where losing it would cause real harm, such as losing drafts or saved work. For a site the user visits rarely, requesting persistence is disproportionate, and the browser may deny it. Use persistence where offline data is core to the product, and explain to the user, where relevant, that their saved work is kept on the device.
Graceful degradation means the app works when storage is limited or cleared. If caches are evicted, the app should fetch from the network, show a loading state, and repopulate its caches, rather than showing a broken page or a stale error. If a database for user data is unavailable, the app should tell the user that their work cannot be saved offline at that moment, and offer to retry, rather than silently discarding it. Test degradation by clearing site data, by simulating low storage where developer tools allow it, and by going offline after clearing caches, to see whether the app recovers or fails. An app that only works when its caches are intact is not a robust PWA; it is a cached website that breaks when the cache is gone.
Monitor storage use in development, by checking cache sizes and database sizes in developer tools, and set limits for runtime caches, as covered earlier, so that storage use stays within a reasonable bound. A PWA that uses hundreds of megabytes for a simple content app is likely caching too much, or caching large resources unnecessarily, and it will be a candidate for eviction. Keep storage lean, cache what matters, expire what does not, and degrade gracefully when storage is lost. That discipline is what makes offline support sustainable, rather than a feature that works until the device runs out of space.
Check: Why should a PWA not assume its cached data will never be evicted?
Section 24 — IndexedDB for offline data, not just files
Cache Storage is for responses: HTML, stylesheets, scripts, images, and data files fetched from the network. IndexedDB is for structured data: records, lists, user content, and application state that the app creates, updates, and queries. A PWA that lets users create, edit, or save content offline needs a database on the device, not just a cache of network responses, because cached responses are keyed by request, not by record, and they cannot be queried, updated, or searched as data. IndexedDB provides a transactional, indexed database in the browser, where the app can store objects, query them by key or by index, and update them as the user works, all offline, and then synchronise them with the server when back online, using background sync or a manual sync.
Using IndexedDB well requires thinking about data modelling, not just storage. Records should have stable identifiers, so that an update to a record offline can be matched to the same record on the server, rather than creating a duplicate. Changes made offline should be recorded as changes, with a timestamp or a version, so that sync can detect conflicts, a topic covered next. Queries should use indexes for fields the app searches or sorts by, rather than scanning all records, because a database that grows to thousands of records will become slow if every query scans everything. IndexedDB's asynchronous API, with its transactions and event model, takes some learning, and many developers use a small wrapper library to simplify it, but the underlying concepts — stores, keys, indexes, and transactions — remain the same, and understanding them helps you debug when a wrapper hides a problem.
IndexedDB also has limits and failure modes. Like Cache Storage, it shares the site's storage quota and may be evicted, though persistent storage can reduce that risk. It is asynchronous, so code must handle promises or events, and must not assume a write has completed before its transaction finishes. It is also origin-scoped, so data stored by one site cannot be read by another, which is a security feature, but it also means data cannot be shared across origins without a server. Test IndexedDB by creating, updating, deleting, and querying records offline, by closing and reopening the app, and by checking that data survives a restart and a worker update, since the database is separate from the worker's caches and should not be deleted during cache cleanup.
Choose IndexedDB when the app needs to store and query user data offline, and Cache Storage when it needs to store network responses for offline loading. Using the wrong one — caching user data as responses, or storing app shell files as database records — will produce code that is hard to query, hard to update, and hard to sync. Each storage type has its job, and a PWA that uses both, each for its purpose, can offer a full offline experience: the shell and content from caches, and the user's own data from the database, ready to sync when the connection returns.
Check: Why is Cache Storage not a substitute for a database for user-created data?
Section 25 — Sync conflicts: when offline edits collide
When a user edits data offline, and another user, or the same user on another device, edits the same data online, a conflict occurs when the offline edits are synced. Neither version is automatically correct, and the sync system must decide what to do: keep one version, merge them, or ask the user. Ignoring conflicts, by simply overwriting the server's version with the offline version, or vice versa, will lose someone's work, and that loss may not be discovered until much later, when the overwritten edit is needed. Conflict handling is therefore a core part of offline design, not an edge case to be handled later, and it should be designed before offline editing is offered, not after users lose work.
The simplest conflict strategy is last-write-wins, where the most recent edit, by timestamp, replaces earlier ones. That strategy is easy to implement, but it loses edits, and its timestamps may be unreliable if device clocks differ, which they often do. A better strategy, where the data allows it, is merging: for example, if two users edited different fields of the same record, both edits can be kept, because they do not conflict. For text, merging is harder, and may require showing both versions and asking the user to choose, or to edit a merged version. For some data, such as a counter or a list, a conflict-free data structure can be used, but that is an advanced technique and should not be assumed without understanding its trade-offs. The right strategy depends on the data: field-level merging for structured records, user choice for text, and last-write-wins only where losing an edit is acceptable and understood.
The interface for conflicts matters as much as the strategy. When a conflict cannot be resolved automatically, the user should be shown both versions, or a merged preview, with a clear explanation of what happened and a choice of how to proceed, without losing either version in the process. The conflicted versions should be preserved until the user resolves them, not discarded after a single choice, in case the choice was a mistake. The user should also be told when a sync has occurred, and whether any of their offline edits were changed or discarded, so they can check their work. Silent conflict resolution, where the system picks a winner without telling the user, will eventually discard an edit the user valued, and the user will learn not to trust offline editing.
Test conflicts by editing the same record offline on two devices, or offline and online, then syncing, and checking what happens: is any edit lost silently, is the user asked, and can they recover both versions? Test also with clock differences, by setting one device's clock differently, to see whether last-write-wins behaves sensibly. Offline editing without conflict handling is a promise the product cannot keep. With honest conflict handling, even if it sometimes requires the user to choose, offline editing becomes trustworthy, because the user knows their work will not vanish silently when it meets another edit.
Check: Why is silently overwriting one edit with another a bad conflict strategy?
Section 26 — Handling fetch events in the worker
The fetch event is the heart of a service worker's network role. Every time a page controlled by the worker makes a network request — for HTML, CSS, JavaScript, an image, or data — the worker receives a fetch event, and it can decide how to respond: serve from cache, fetch from network, combine both, or serve a fallback. The worker responds by providing a response to the event, using the event's respond method, and that response becomes what the page receives, as if it came from the network. If the worker does not handle the event, the request goes to the network as normal. That interception is powerful, and it must be used carefully, because a worker that handles every request incorrectly can break the whole site, while one that handles only the requests it understands will leave the rest to work normally.
A fetch handler should be selective. It should handle requests for resources it knows how to cache or serve offline, such as app shell files, images, and data files, and it should let other requests, such as third-party analytics, authentication, or sensitive data, pass through to the network untouched, unless there is a clear reason to intercept them. Handling a request for a sensitive resource with a cached response could serve one user's data to another, or serve stale authentication state, which is a security risk. The handler should also distinguish request types: navigation requests, which load a page, need an offline fallback; asset requests, which load styles or scripts, need a caching strategy; and data requests, which load content, need a freshness strategy. Treating all requests the same, with a single strategy, will produce wrong behaviour for at least one type.
The fetch handler must also handle errors. A network fetch can fail, a cache lookup can miss, and a cached response can be corrupt or outdated. The handler should have a fallback chain for each type: try cache, then network, then fallback; or network, then cache, then fallback, depending on the strategy chosen for that resource. It should not throw an unhandled error that leaves the page with no response, and it should not serve a fallback for a request where a fallback would be misleading, such as serving a cached HTML page for a data request that expects JSON. Test the handler by going offline, by throttling the network, and by requesting resources that are not cached, and check that each request receives a sensible response or a clear failure, not a hang or a wrong-type response.
Finally, keep the fetch handler fast and simple. A handler that does complex work on every request will slow every page load, and a handler that blocks on a slow cache or network operation will delay the response the user is waiting for. Use the cache APIs efficiently, avoid unnecessary work, and let unhandled requests pass through quickly. The fetch event is where a PWA's offline and performance promises are kept or broken, request by request, and a handler that is selective, type-aware, error-tolerant, and fast will keep them, while a handler that is indiscriminate or fragile will break them for every user.
Check: Why should a fetch handler not treat navigation, asset and data requests identically?
Section 27 — Navigation requests and the app shell pattern
Navigation requests are the requests a browser makes when a user navigates to a page, by clicking a link, typing an address, or using back and forward. In a PWA, navigation handling is special, because the app shell pattern relies on serving the shell for navigation requests, even when the specific page was not precached. When a user navigates to a route that is handled by the app's client-side routing, the worker can serve the cached shell HTML, and the app's JavaScript then renders the correct content for that route, from cached data or from the network. That pattern lets a single-page app work offline for routes the user has not visited before, as long as the shell and the routing logic are cached, even if the specific content for that route is not.
Implementing navigation handling requires distinguishing navigation requests from other requests, which the worker can do by checking the request's mode or destination. A navigation request should be served from the cached shell, or from a network fetch with a cache fallback, and, if neither is available, from the offline fallback page. It should not be served from a generic cache-first strategy that might return the wrong page's HTML, because serving one page's HTML for another route will confuse the app's routing and show the wrong content. The shell served for navigation should be the same shell for every route, so that the app's routing can take over consistently, and it should be precached, so it is always available offline.
Client-side routing and navigation handling must be coordinated. If the app uses client-side routing, its routes must be known to the worker, or the worker must serve the shell for all navigation requests under its scope, letting the app decide whether a route exists and showing a not-found page within the app if it does not. If the app uses server-side routing, with a distinct HTML page per route, then navigation handling should fetch or cache each page individually, rather than serving a single shell for all routes, because the shell pattern does not apply. Mixing the two approaches without coordination — serving a shell for a server-routed page, or fetching a distinct page for a client-routed app — will produce broken navigation, where the wrong content appears or the app's routing fails.
Test navigation handling by navigating to several routes online, then offline, and checking that each route loads correctly, that unknown routes show an in-app not-found page rather than a browser error, and that back and forward navigation works offline as well as online. Test also deep links, where a user opens a specific route directly, rather than navigating from the homepage, to ensure the shell is served and the route renders. Navigation is the user's main way of moving through an app, and a PWA whose navigation breaks offline has failed at the most basic app-like promise: that the app is present and navigable, even without a connection.
Check: Why should a single-page app's worker serve the same shell for different routes?
Section 28 — Performance: the app shell and fast first paint
Performance in a PWA is not just about caching; it is about how quickly the user sees something useful, especially on first visit and on repeat visits. The app shell pattern helps performance because the shell, once cached, loads instantly, and the app can show its interface, navigation, and loading states immediately, while content loads in the background. That instant shell is what makes a repeat visit feel app-like: the user taps the icon and sees the app immediately, not a blank page waiting for a network round trip. For first visits, when the shell is not yet cached, performance depends on the shell's size and on how quickly the server can deliver it, so the shell should be lean: minimal HTML, critical CSS, and only the JavaScript needed to render the initial interface, with the rest loaded afterwards.
First paint and first meaningful content are the moments when the user first sees something, and they should happen quickly, even on a slow connection. A shell that includes large images, unneeded scripts, or blocking resources will delay those moments, and the user will perceive the app as slow, even if its total load time is reasonable. Optimise the shell by inlining critical CSS, deferring non-critical scripts, compressing resources, and avoiding render-blocking requests that are not needed for the initial view. Test performance on a throttled connection and on a low-end device, not just on a fast desktop, because the users who benefit most from a PWA's offline and performance features are often those on slower connections and less powerful devices.
Caching contributes to performance on repeat visits, but it must be balanced against freshness, as covered in earlier strategies. A cache-first shell gives instant repeat loads, but it must be updated promptly when the shell changes, or users will see an old interface. A network-first shell gives fresh repeat loads, but it loses the instant feel. Stale-while-revalidate for the shell, or versioned precaching with prompt updates, offers a balance: instant shell from cache, with a background update for the next visit. Whichever strategy is chosen, measure its effect: time to first paint, time to interactive, and time to content, on repeat visits as well as first visits, and compare them with and without the service worker, to ensure the worker is helping, not hindering.
Finally, performance is not only about the shell. Content loading, image sizes, and data fetching all affect how fast the app feels after the shell appears. Use lazy loading for images below the fold, compress images appropriately, and avoid fetching large data sets that the user has not requested. A fast shell followed by a slow content load still feels slow, because the user came for the content, not for the shell. Measure the whole journey, from tap to usable content, and optimise the slowest part first, rather than optimising the shell in isolation while content remains slow. A PWA that paints fast and delivers content promptly, on repeat visits and on slow connections, is a PWA that feels like an app, not just one that claims to be one.
Check: Why should the app shell be lean on first visit, not just on repeat visits?
Section 29 — Auditing a PWA with browser tools
Browser developer tools provide a dedicated audit for PWAs, which checks the manifest, the service worker, icons, HTTPS, and other installability and performance criteria, and reports what passes and what fails. Running that audit is a useful step before launch and after any major change, because it catches common mistakes: a missing manifest field, an icon that is too small, a service worker that does not handle offline use, or a page that is not served over HTTPS. The audit's report should be read as a checklist, not as a score to chase: a passing audit does not guarantee a good PWA, and a failing item does not always mean the product is broken, but each failure points to a specific, fixable issue that should be understood and addressed or consciously accepted with a reason.
Beyond the automated audit, developer tools let you inspect the manifest, the service worker's status and scope, Cache Storage contents, and IndexedDB data, and let you simulate offline mode, throttled networks, and different devices. Use those tools to verify the behaviours this chapter teaches, rather than relying on the audit alone. Check that the manifest is found and valid, that the worker is registered and controlling pages, that caches contain the expected resources, that going offline shows the offline fallback or cached content, and that an update creates a new worker that waits correctly. Those manual checks reveal problems an automated audit may miss, such as a worker that passes installability but serves the wrong cached page for a route, or a cache that contains stale resources the audit does not inspect.
Audits also have limits. An automated check cannot tell whether an offline page's wording is helpful, whether a notification is welcome, whether a shortcut points to a useful destination, or whether a caching strategy matches a resource's freshness needs. Those judgements require human review, user testing, and real-device testing, as covered throughout this chapter. An audit that passes while the product's offline experience is confusing, or its notifications are spammy, has not validated the product; it has only validated its technical setup. Use the audit as a starting point, then test the experience as a user would, on a real device, offline, and after an update.
Run the audit regularly, not just at launch, because manifest changes, worker updates, and server changes can break installability or offline support silently. Record the audit results, fix failures, and re-run to confirm. A PWA that is audited once and never again will drift: icons will go missing, caches will go stale, and HTTPS may lapse, and users will discover those failures before the team does. Regular auditing, paired with manual and real-device testing, keeps a PWA's technical foundation sound, so that its user experience can be trusted.
Check: Why is a passing automated PWA audit not a guarantee of a good PWA?
Section 30 — iOS, Android and platform differences
PWAs do not behave identically on every platform, and understanding the differences is essential to setting honest expectations. On Android, through Chrome and other Chromium-based browsers, PWAs support installation with a full manifest, service workers, background sync, push notifications, and app shortcuts, and they can feel close to native apps. On iOS, through Safari, PWA support has grown, but it has historically differed: installation is through the share menu rather than an automatic prompt, some background features are limited or behave differently, and push notification support and other capabilities have arrived later or with different constraints. Those differences are not static; they change as browsers update, so you should check current platform documentation and test on current devices, rather than relying on a fixed list from memory or from an old article.
The practical consequence is to design for the lowest common denominator where a feature matters, and to enhance where a platform supports more. If push notifications are essential to a product, and a platform does not support them reliably for PWAs, then a PWA alone may not serve users on that platform, and the team must decide whether to accept that limit, to offer an alternative notification method, or to build a native app for that platform. If offline use is essential, and a platform limits storage or background sync, then the offline design must account for those limits, by caching less, by syncing more explicitly, or by warning the user when offline work may not persist. Designing as if every platform supported every feature equally will produce a product that works on the developer's platform and disappoints on others.
Installation flow also differs. On Android, a browser may show an install prompt automatically when installability conditions are met, and the user can install with a tap. On iOS, the user must find the share menu and choose to add to the home screen, a flow that many users do not know, so a PWA that relies on automatic prompting will not be discovered on iOS. Providing clear, in-app instructions for installation on iOS, without being intrusive, can help, but those instructions must be accurate for the current iOS version and browser, and they must not pretend to be a system prompt. Test the installation flow on each platform, following the steps a real user would take, and check that the installed app's icon, name, and display mode are correct on each.
Test PWAs on real Android and iOS devices, with current browser versions, for installation, offline use, notifications, and updates, and record platform-specific behaviours and limits. Do not promise users on one platform a feature that only works on another, and do not design a feature that depends on a platform-specific behaviour without a fallback. Platform differences are a reality of the web, not a flaw in PWAs, and a team that acknowledges them, designs around them, and tests on real devices will build a PWA that works well everywhere it can, and honestly where it cannot.
Check: Why must PWA installation instructions differ between Android and iOS?
Section 31 — The install prompt: timing it well
The install prompt is the browser's offer to install a PWA, and when it appears matters as much as whether it appears. A prompt shown on first visit, before the user has seen any value, is likely to be dismissed, and a dismissed prompt may not return, or may return only after a delay, depending on the browser. A prompt shown after the user has engaged — visited several pages, returned on another day, or completed a meaningful task — is more likely to be accepted, because the user has evidence that the app is worth keeping. Timing the prompt is therefore a design decision, not just a technical one, and it should be based on engagement, not on the earliest moment the browser allows it.
Browsers provide an event that fires when the install prompt would be shown, allowing the developer to defer it and to show it later, at a chosen moment, through a custom install button or message. Deferring the prompt lets you control its timing: you can wait until the user has completed a task, visited a certain number of times, or explicitly asked how to install, and then show the prompt in context, with an explanation of why installing helps, such as offline access or a home screen shortcut. That explanation matters: a prompt that appears without context feels like an interruption, while one that appears after a user has said "I use this every day" feels like a helpful offer. Do not show a custom install button that pretends to be a system prompt, and do not show the prompt repeatedly after a dismissal, because repeated prompting teaches users to dismiss it automatically.
The install experience should also be discoverable without a prompt. Some users will never see an automatic prompt, because of platform differences, because they dismissed it, or because their browser does not show one, and they should still be able to install through the browser menu or through an in-app install guide. Provide a clear, unobtrusive install option in settings or in a help section, explaining what installing does and how to do it on the user's platform, without pressuring them. That option should be available but not intrusive, so that users who want to install can find it, and users who do not are not nagged.
Test the install prompt timing by simulating first visits, returning visits, and engaged visits, and checking when the prompt appears and how it behaves after acceptance, dismissal, and installation. Check also what happens after installation: the prompt should not reappear for an installed user, and the app should open in its installed mode, not in a browser tab, when launched from the home screen. A well-timed prompt, deferred until value is proven, explained in context, and never repeated after dismissal, will convert more users to installed users than an early, unexplained prompt, and it will do so without annoying the users who choose not to install.
Check: Why is an install prompt shown after engagement more likely to be accepted?
Section 32 — Handling the install prompt event
The browser fires an event before the install prompt would be shown, giving the developer a chance to defer it and to trigger it later at a chosen moment. Handling that event correctly involves storing it, preventing the default automatic prompt where you want to defer, and then triggering the stored prompt when the user takes an action that suggests they want to install, such as tapping an install button. That pattern — capture, defer, trigger on user action — is the standard way to control install timing, and it should be implemented carefully, because the stored event can only be used once, and it may expire or become invalid if not used promptly. If you capture the event but never trigger it, the user will never see a prompt, and your install button will do nothing, which is worse than letting the automatic prompt appear.
Implementing the pattern requires handling several states. Before the event fires, no install button should be shown, or it should be shown as a guide to manual installation, because the browser has not yet determined installability. After the event is captured, an install button can be shown, enabled, and wired to trigger the stored prompt. After the user accepts or dismisses the prompt, the button should be hidden or changed, because the event cannot be reused, and showing a button that does nothing will confuse users. After installation, detected through another event or through display mode checks, the button should also be hidden, because the app is already installed. Each state transition should be tested, because a button stuck in the wrong state is a common PWA bug.
The prompt's result should be handled gracefully. If the user accepts, the app should acknowledge installation, perhaps by hiding the install button and showing a brief confirmation, but it should not interrupt the user's current task with a celebration. If the user dismisses, the app should respect that choice, hide the button for a sensible period, and not show it again on the next page load, because repeated prompting after dismissal is intrusive. The dismissal should be remembered, at least for the session, and ideally for a longer period, so that a user who said no is not asked again immediately. That respect for dismissal is part of earning trust, and it also improves the chances that a future prompt, shown after further engagement, will be accepted.
Test the event handling by visiting the app in a browser that supports the event, checking that the button appears only after the event, that triggering it shows the prompt, that accepting and dismissing both update the button correctly, and that reinstalling or uninstalling resets the state sensibly. Test also in a browser that does not support the event, to ensure the app still offers a manual install guide and does not show a broken button. Handling the install prompt event is a small piece of code, but it controls the user's first impression of installation, and getting its states right is what separates a polished PWA from one whose install button does nothing.
Check: What must happen to a stored install prompt event after it is used once?
Section 33 — Offline forms and queued submissions
Offline forms are forms that can be filled and submitted while offline, with the submission queued and sent when the connection returns, using background sync or a similar mechanism. That capability transforms a form from a task that requires a connection into a task that can be done anywhere, which matters for users on unreliable connections, in rural areas, or in places where connectivity is intermittent. Designing an offline form starts with the same principles as any form — clear labels, validation, error handling — plus the additional requirements of local storage, queue status, and sync handling, which must be designed together, not added as an afterthought to a form that assumes it is always online.
Validation for offline forms should happen locally, before queueing, so that a submission queued offline is already valid, and will not fail on sync for a reason that could have been caught earlier. That local validation must match server validation, so that a queued submission does not fail on sync and require the user to fix it days later, when they may have forgotten the context. The queued submission should be stored persistently, with its data, its timestamp, and its status, and the user should be able to view queued submissions, edit or delete them before they send, and see when each was sent. A queue that is invisible, where submissions vanish into the background with no way to check them, will cause anxiety and duplicate submissions, as users resubmit because they cannot tell whether the first submission was queued.
Status communication is essential. A form submitted offline should show a clear pending state, such as "Saved offline. Will send when back online", and that state should update to sent when sync succeeds, or to failed with a reason and a retry option when sync fails. The user should not have to guess whether their submission was received, and they should not be told it was sent when it was only queued, as covered under background sync. If a queued submission fails on sync because the data is now invalid — for example, because a referenced item no longer exists — the user should be told specifically what failed and how to fix it, with the original data preserved for editing, not discarded.
Test offline forms by filling and submitting while offline, checking the queue, going online and checking sync, and checking failure handling by making the server reject a queued submission. Test also editing and deleting a queued submission before sync, and submitting the same form twice offline to check for duplicates. An offline form that validates locally, queues visibly, syncs reliably, and reports its status honestly will be trusted for real work offline. One that queues silently, fails silently, or duplicates on sync will teach users not to trust offline submission, which defeats the purpose of building it.
Check: Why must an offline form validate locally before queueing a submission?
Section 34 — Accessibility in an installed app
An installed PWA must be as accessible as the same site in a browser, because installation does not change who uses it, only how they open it. All the accessibility requirements for a website — semantic markup, keyboard navigation, visible focus, screen reader announcements, sufficient contrast, and text alternatives — apply equally in standalone mode, where browser chrome is absent and the app must provide its own navigation. In fact, standalone mode raises the stakes: without the browser's back button, address bar, or bookmarks, a user who relies on keyboard or screen reader navigation depends entirely on the app's own controls to move, to go back, and to understand where they are. An app that is accessible in a browser tab but not in standalone mode has not been made accessible; it has been made accessible only in one of its modes.
Specific accessibility risks in installed apps include navigation, focus, and status communication. Navigation in standalone mode must be complete: every page must be reachable, every back action must be available, and the current location must be clear through headings and landmarks, not just through a URL that is no longer visible. Focus must be managed when the app opens, when it navigates, and when it shows dialogs or offline states, so that keyboard and screen reader users are not lost. Status changes, such as a sync completing, a new version being available, or a connection being lost, must be announced accessibly, not just shown visually, because an installed app user may not be looking at the screen when a background event occurs, and a visual-only status will be missed.
Testing accessibility in installed mode requires installing the app on a real device and testing it there, not just testing the website in a browser. Install the app, navigate with a keyboard where possible, use a screen reader on the device, and check that headings, labels, errors, and notifications are announced correctly in standalone mode. Check also that the app's icon and name are clear, that its splash screen does not trap focus or delay access, and that its display mode does not hide essential controls behind system bars or notches. An installed app that passes a browser accessibility audit but fails on a device in standalone mode has a gap that only device testing will reveal.
Finally, do not treat accessibility as a separate PWA feature. It is part of the PWA's definition of quality, alongside offline support, performance, and installation. A PWA that is fast and offline-capable but inaccessible excludes users who need accessibility most, and it does so in a mode — installed, app-like — where they may have fewer alternatives. Building accessibility into the PWA from the start, and testing it in installed mode as well as in browser mode, ensures that the app-like experience is available to everyone, not just to users who do not need accessibility features.
Check: Why is standalone mode a higher accessibility risk than browser mode?
Section 35 — Debugging service workers in developer tools
Developer tools provide a dedicated panel for service workers, showing registered workers, their status, their scope, and controls to update, unregister, bypass, or simulate offline mode. Learning to use that panel is essential for debugging PWAs, because service worker problems — a worker stuck waiting, a cache serving stale resources, a fetch handler returning the wrong response — are invisible in ordinary page debugging. The panel lets you see whether a worker is installed, waiting, or active, whether it controls the current page, and whether its caches contain the expected entries. Without that visibility, debugging a PWA is guesswork, and developers often resort to clearing all site data, which hides the problem rather than fixing it.
Common debugging tasks include: forcing an update to test a new worker version, bypassing the worker to check whether a problem is caused by the worker or by the page, unregistering a worker to test first-visit behaviour, and simulating offline mode to test offline fallbacks. Each of those controls should be used deliberately, and the results should be checked against expectations: after forcing an update, does the new worker activate correctly? After bypassing, does the page work normally from the network? After unregistering, does registration happen again on reload? Those checks isolate whether a bug is in the worker, in the page, or in the interaction between them, which is the first step to fixing it.
Cache inspection is another essential debugging tool. Developer tools can list Cache Storage entries, show their sizes, and allow individual entries to be deleted, which lets you test whether a stale cache entry is causing a problem, by deleting it and reloading. Network inspection, combined with service worker inspection, shows whether a request was served from cache, from network, or from a fallback, and how long each took. Console logs from the service worker appear in a separate context from page logs, so checking the worker's logs, not just the page's, is necessary when debugging fetch handling, sync events, or push handling. A worker that logs its decisions — cache hit, network fetch, fallback served — is far easier to debug than one that works silently.
Test debugging skills by deliberately introducing a worker bug, such as a wrong cache name or a fetch handler that serves the wrong response, and using developer tools to find it, rather than by clearing data and starting over. That practice builds the habit of inspecting workers, caches, and logs, which is the habit that will save hours when a real PWA bug appears in production. Debugging a service worker is not the same as debugging a page, because the worker lives separately, persists across visits, and controls requests invisibly. Developer tools make that invisible worker visible, and learning to use them is a core PWA skill.
Check: Why should you inspect a worker's logs separately from the page's logs?
Section 36 — When caches serve the wrong thing
Caches serve the wrong thing when a cache key, a strategy, or a version does not match the request it is serving. Common causes include: caching a response under a key that does not distinguish between users, so one user's data is served to another; caching a response for a request that includes a query parameter, but ignoring that parameter when matching, so a filtered view serves unfiltered data; caching an error response, so a temporary failure is served repeatedly as if it were content; and serving a cached HTML page for a data request, or vice versa, because the fetch handler did not distinguish request types. Each of these errors produces a symptom that looks like a data bug — wrong user, wrong filter, persistent error — but whose cause is in the cache, not in the data.
Preventing wrong-cache errors starts with cache keys and request matching. A cache match should consider the full request, including its method, its URL including query parameters where they affect content, and, where relevant, headers that affect the response, such as language or authentication. A response that varies by user should generally not be cached in a shared cache, or should be cached with a key that includes the user, and with a clear expiry, so it cannot leak to another user. An error response should not be cached, or should be cached only briefly, so a temporary failure does not become permanent. A fetch handler should check the request type before choosing a cached response, and should not serve a cached response of the wrong type, even if a cache entry exists for a similar URL.
Detecting wrong-cache errors requires testing with multiple users, multiple filters, and error conditions, not just with a single happy-path visit. Test by logging in as one user, caching content, logging out, logging in as another, and checking that no first user's data appears. Test by applying filters, caching a filtered view, changing the filter, and checking that the new view is correct. Test by causing a server error, caching it inadvertently, and checking whether the error persists after the server recovers. Those tests reveal cache key and strategy errors that a single-user test will miss, because a single user, with a single filter, and no errors, will see correct cached content even when the cache logic is wrong for other cases.
Fixing a wrong-cache error usually involves changing the cache key, the strategy, or the fetch handler's matching logic, and then clearing the affected caches, so that wrong entries do not linger after the fix. A fix that changes code but leaves wrong entries in existing users' caches will appear to fail, because those users will still see wrong content until their caches are updated or cleared. Versioning caches, so a fix creates a new cache and old wrong entries are deleted on activation, helps here. Wrong-cache errors are a reminder that caching is not just about speed; it is about correctness, and a cache that serves the wrong thing quickly is worse than no cache at all.
Check: How could a cache serve one user's data to another user?
Section 37 — Security: scope, HTTPS and sensitive data in caches
Security for PWAs centres on three controls: HTTPS, scope, and careful handling of sensitive data in caches. HTTPS, covered earlier, ensures the service worker and its resources are delivered without interception or alteration. Scope ensures the worker controls only its intended pages, so a worker for one section cannot intercept requests for another. Sensitive data handling ensures that private information — authentication tokens, personal messages, payment details, health data — is not stored in caches or databases in a way that could be read by another user, by another origin, or by a future worker version that should not have access. Together, these controls protect the powerful capabilities a service worker has, and neglecting any one of them creates a risk that the other two cannot fully mitigate.
Sensitive requests should generally bypass the cache, or be cached only with strict limits. A request that returns personal data, such as a user's profile, messages, or account details, should not be cached in a shared, long-lived cache, because that cache may persist after logout, may be served to a different user on a shared device, or may be read by a worker update that changes its handling. If such data must be cached for offline use, it should be cached in a way that is tied to the user, cleared on logout, and encrypted or protected where the platform supports it, and the user should be told that their data is stored offline and how to clear it. Never cache a full authentication token, a password, or a payment credential in Cache Storage, because those values are not content to be served offline; they are secrets that should be handled through secure, short-lived mechanisms.
Worker code itself must be treated as security-critical. A worker that logs sensitive data, that includes third-party scripts without review, or that handles requests without checking their origin, can leak data or be exploited. Review worker code for logging, for third-party dependencies, and for request handling that might serve cached sensitive data to the wrong context. Keep the worker's code minimal and auditable, and avoid adding features to the worker that do not need to be there, because each addition increases the surface for bugs. A worker that only handles app shell and public content, and lets sensitive requests pass through to the network, is safer than one that tries to cache everything.
Test security by checking HTTPS, scope, and cache contents after using the app as different users, after logout, and after a worker update. Check that no sensitive data remains in caches after logout, that no user's data appears for another user, and that sensitive requests are not served from cache when they should not be. Security testing for PWAs is not a one-time audit; it should be repeated whenever the worker, the caching strategy, or the data model changes, because each change can introduce a new way for sensitive data to be cached, served, or leaked.
Check: Why should sensitive personal data generally bypass the cache?
Section 38 — Using a library for service workers, with care
Writing a service worker by hand teaches you how it works, and for a simple PWA, hand-written code may be sufficient and clear. For a larger PWA, with complex caching, precaching, routing, and update handling, a library can help by providing tested patterns for common tasks, such as precaching with hashed filenames, runtime caching with limits, and cache cleanup on activation. Using a library does not remove the need to understand service workers; it changes the work from writing low-level cache code to configuring the library correctly, understanding its defaults, and testing its behaviour. A library misconfigured — with the wrong precache list, the wrong runtime strategy, or the wrong cache names — will produce the same bugs as hand-written code, but they will be harder to find, because the code that implements them is not yours.
Choosing a library requires checking its maintenance, its documentation, and its fit for your app. A library that is unmaintained, or that assumes a build system you do not use, will cause more problems than it solves. A library that is well documented, actively maintained, and designed for your build setup is more likely to help. Do not choose a library because it is popular alone; choose it because its caching patterns match your app's needs, its configuration is clear, and you understand what it will do on install, on activation, on fetch, and on update. Read its documentation for those lifecycle events, not just for its getting-started example, because lifecycle behaviour is where PWA bugs live.
Even with a library, test the generated worker as you would a hand-written one: check its precache list, its cache names, its fetch handling for different request types, its offline fallback, and its update behaviour. Inspect the generated worker file, at least once, to understand what it does, and check that it does not precache resources it should not, cache sensitive data it should not, or use a strategy that mismatches a resource's freshness needs. A library's defaults are designed for a generic app, not for yours, and they should be reviewed and adjusted for your app's specific resources, just as hand-written strategies would be.
Finally, keep the library's version and configuration under review. A library update may change defaults, cache names, or strategies, and that change may affect existing users' caches, requiring a worker update and cache cleanup to be handled correctly. Do not update a service worker library without testing the update's effect on existing caches and on offline behaviour, because a library change that looks minor in code can change caching behaviour for all users. Using a library with care — understanding it, configuring it, testing its output, and reviewing its updates — gives you tested patterns without surrendering understanding, which is the right balance for a production PWA.
Check: Why does using a service worker library not remove the need to understand service workers?
Section 39 — Precaching versus runtime caching trade-offs
Precaching and runtime caching are complementary, and choosing between them for each resource is a trade-off between install speed, storage use, and offline reliability. Precaching guarantees that a resource is available offline from the first visit after installation, because it is stored during install, but it slows installation, uses storage for resources the user may never need, and can fail installation if a resource is unavailable. Runtime caching avoids those costs, by storing a resource only when the user actually requests it, but it guarantees nothing: a resource the user has not visited will not be available offline, and a first offline visit to a new page will fail if its content was never cached. The trade-off is therefore between certainty and efficiency, and the right balance depends on the resource.
App shell resources — HTML, core CSS, core JavaScript, icons — favour precaching, because they are needed for every visit, they are relatively small, and their absence offline breaks the whole app. Content resources — articles, images, data files — favour runtime caching, because there are many of them, each user visits only a few, and precaching all of them would be slow and wasteful. A hybrid is often best: precache the shell, runtime-cache content the user visits, and optionally precache a small set of high-value content, such as a welcome article or a help page, that every offline user should have. That hybrid gives a reliable shell offline, useful content for visited pages, and a fast install, without precaching the entire site.
Storage and update considerations also affect the trade-off. Precached resources are versioned with the app shell, so they update together, which keeps shell and code in sync, but it also means a shell update discards and re-downloads all precached resources, even those that did not change, unless hashed filenames are used to avoid re-downloading unchanged files. Runtime-cached resources update independently, when visited, which is more efficient for content that changes rarely, but it can leave a mix of old and new content if some resources are updated and others are not. Neither approach is universally better; each must be judged by how the resource changes, how often it is used, and how important it is offline.
Test the trade-off by measuring install time with different precache sets, by checking offline availability for shell and for visited versus unvisited content, and by checking storage use after typical use. If install is slow, precache less. If offline use is disappointing because key content was never cached, precache more, or offer a "save offline" action for content the user chooses. That user-chosen offline saving, where the user explicitly marks content to keep offline, is a valuable pattern: it lets the user decide what matters offline, rather than the developer guessing, and it makes runtime caching intentional rather than accidental. Precaching versus runtime caching is not a binary choice; it is a spectrum, and a good PWA places each resource on that spectrum deliberately.
Check: Why is precaching every article on a news site a poor choice?
Section 40 — Update prompts and the refresh experience
When a new version of a PWA is available, the user needs to know, and needs a clear, non-disruptive way to get it. The common pattern is a small, dismissible message, such as "A new version is available. Refresh to update", shown when a new service worker has installed and is waiting. That message should appear at a moment when refreshing is safe, not in the middle of a form or a task, and it should be dismissible, so a user who is busy can continue and update later. It should not force a reload, auto-reload after a countdown, or show a full-screen modal that blocks work, because those patterns treat the user's session as less important than the update, and they risk losing unsaved work. The update is for the user's benefit; it should not punish them for using the current version.
The refresh experience itself needs care. When the user taps refresh, the new worker should activate, the page should reload, and the new version should appear cleanly, without a mix of old and new resources, and without losing the user's place where that can be preserved. If the user was on a specific page, refreshing should return them to that page in the new version, not to the homepage, unless the new version has changed routing in a way that makes that impossible. If the user had unsaved work, such as a draft, that work should be preserved across the refresh, through draft saving or local storage, rather than discarded by the reload. Test the refresh by updating the app while a user is on a deep page with a draft, and checking that the draft survives and the page returns correctly in the new version.
Update prompts should also handle the case where the user dismisses the prompt repeatedly. A prompt dismissed several times should not reappear on every page load, because that becomes nagging, and nagging prompts are dismissed without reading, including when the update is important. Remember dismissals, at least for a session, and consider a longer interval before re-prompting, or a subtler indicator, such as a small badge on a settings icon, that the user can check when convenient. For critical updates, such as a security fix, a stronger prompt may be justified, with a clear explanation of why the update matters, but even then, it should not discard unsaved work without warning.
Finally, test update prompts on real devices, in standalone mode as well as in browser mode, because a prompt that looks like a small banner in a browser may look like a blocking dialog in a standalone app, or may be hidden behind system bars. Check that the prompt is accessible, focusable, and dismissible by keyboard and screen reader, and that its refresh action works in both modes. An update prompt is a small piece of interface, but it is the moment when a PWA changes underneath a user, and handling that moment respectfully — with information, choice, and preservation of work — is what makes updates feel like improvements rather than interruptions.
Check: Why should an update prompt not force an immediate reload?
Section 41 — Online and offline status in the interface
A PWA should tell the user, clearly but unobtrusively, whether it is online or offline, because that status affects what the user can do. An offline indicator, such as a small banner or icon, that appears when the connection is lost and disappears when it returns, helps the user understand why a page did not load, why a submission is queued, or why content is older than expected. Without that indicator, a user who goes offline may think the app is broken, may retry actions that are already queued, or may assume a submission failed when it is merely waiting. With it, the same user understands the situation and can act accordingly, by viewing cached content, by waiting, or by retrying later.
Status detection in the browser is based on network events, which fire when the browser believes the connection has changed, but those events are not perfect: a browser may report online when the connection is actually captive, slow, or unable to reach the server, and it may report offline only after a request fails. A PWA should therefore not rely solely on status events, but should also handle request failures gracefully, showing an offline state when a fetch fails, even if the browser still reports online. Conversely, it should not show an offline banner permanently because a single request failed, when other requests succeed. Use status events as a hint, and request outcomes as evidence, and update the interface based on the combination, with a clear, honest message in each case.
The offline indicator's design matters. It should be visible but not intrusive, so that it informs without blocking content. A banner at the top of the page, or a small icon in the header, with text such as "You are offline. Some content may be out of date", is more helpful than a full-screen modal that prevents the user from viewing cached content. The indicator should also be accessible, announced to screen readers when status changes, and dismissible or persistent as appropriate: a persistent indicator for as long as offline is honest, while a dismissible one that hides the status may mislead the user into thinking they are back online. When the connection returns, the indicator should disappear, and any queued actions or stale content should update, with a brief confirmation where that helps, such as "Back online. Your saved changes have been sent", where that is true.
Test status handling by going offline and online repeatedly, by using a captive portal or a throttled connection where possible, and by performing actions in each state, checking that the indicator appears and disappears correctly, that queued actions update, and that no action is lost or duplicated during transitions. Test also with a screen reader, to ensure status changes are announced. Online and offline status is not a technical detail to hide; it is information the user needs to understand what the app can do right now, and a PWA that communicates it clearly will be trusted in both states, while one that hides it will confuse users at exactly the moments when clarity matters most.
Check: Why should a PWA not rely only on the browser's online event to show status?
Section 42 — Sharing, share target and app integration
Sharing is a common app-like feature, and PWAs can participate in it in both directions: sharing content out of the app, and receiving shared content into the app. Sharing out, through the platform's share interface, lets a user send a link, text, or file from the PWA to another app, such as a messaging app or email, using the device's native share sheet, where supported. That share should be offered where sharing makes sense, such as on an article, a product, or a saved item, and it should share a clean link and a clear title, not a cluttered URL with tracking parameters that the recipient does not need. Sharing out should also degrade gracefully where the platform does not support it, by offering a copy-link action instead, so users on unsupported platforms can still share.
Receiving shared content, through a share target declared in the manifest, lets a PWA appear in the device's share sheet when a user shares from another app, and lets it receive the shared title, text, or link, and handle it, for example by saving it, opening it, or creating a new item from it. A share target should be declared only where receiving shares makes sense for the app's purpose: a notes app, a reading list, or a task app benefits from receiving shares, while a game or a calculator generally does not, and declaring a share target for an app that cannot handle shares will confuse users who share to it and find nothing useful happens. The share target's handling page should be fast, clear, and able to work offline where possible, so that a shared item is not lost if the user shares while offline.
Other app integrations, such as handling certain file types, protocols, or links, follow the same principle: declare only what the app can genuinely handle, and handle it well, including offline and error cases. A PWA that declares it can handle a file type but fails to open that file, or that claims a link pattern but shows a broken page for it, will disappoint users who trusted the declaration. Each integration should be tested end to end, from the sharing app or the link source, through the PWA's handling, to the result the user sees, on real devices, because integration behaviour varies by platform and by the source app.
Test sharing out by sharing from the PWA to several target apps, checking that the shared content is correct and that the fallback works where native sharing is unavailable. Test sharing in by sharing to the PWA from another app, checking that the shared content arrives, is handled, and is saved or displayed correctly, including when offline. Sharing integrations are small features, but they connect a PWA to the rest of the device's ecosystem, and a PWA that shares well feels like part of the device, while one that shares poorly feels isolated, however good its core features are.
Check: Why should a PWA only declare a share target if it can genuinely handle shares?
Section 43 — Periodic background sync and its limits
Periodic background sync is a feature that allows a PWA, with permission, to periodically wake its service worker in the background and refresh content, such as news, messages, or data, so that when the user next opens the app, the content is already fresh, even if they have not opened it recently. That capability is appealing for apps where freshness matters, such as news or messaging, because it reduces the wait on open and makes the app feel up to date. It is also limited: it requires permission, it is subject to browser and platform constraints on frequency and battery, and it may not run at all on some platforms, or may run less often than requested, depending on usage, battery, and network conditions. A PWA should therefore treat periodic sync as an enhancement, not as a guarantee, and should always be able to refresh content on open, even if periodic sync did not run.
Permission and user control are central. Periodic sync should only be requested where its value is clear, such as for a news app the user opens daily, and the user should be able to control it, through settings, by choosing which content refreshes in the background and how often, where the platform allows that choice. Requesting periodic sync for an app the user opens rarely, or for content that does not need freshness, wastes battery and data, and may cause the browser to reduce the app's background privileges. A PWA that syncs aggressively in the background, without user understanding or control, will be seen as wasteful, and its background access may be revoked by the browser or by the user.
Periodic sync also interacts with caching and data freshness. Content refreshed in the background should be stored in the same caches and databases as content fetched on open, so that the two paths do not diverge, and background refresh should respect the same freshness rules as foreground fetching, such as not overwriting user-edited data with older server data. Background sync failures should be handled silently, by retrying later or by leaving the existing cached content in place, rather than by showing notifications or errors for a background task the user did not initiate. A background task that fails should not disturb the user, unless the failure affects something the user explicitly asked for, in which case a clear, in-app status on next open is appropriate.
Test periodic sync where the platform supports it, by registering it, by checking whether background refresh occurs, and by opening the app after a period and checking whether content is fresh. Test also where it does not run, by disabling it or by using a platform that does not support it, and checking that the app still refreshes on open and does not show stale content as if it were fresh. Periodic sync is a useful enhancement for the right apps, but its limits — permission, frequency, platform support, and battery — mean it should never be the only freshness mechanism. A PWA that refreshes reliably on open, with periodic sync as a bonus where available, will feel fresh without depending on a background feature that may not run.
Check: Why must a PWA still refresh content on open even if it uses periodic sync?
Section 44 — Push payload privacy and notification taps
Push notification handling in the service worker has two parts: showing the notification, and handling the user's tap on it. Showing should be concise, private, and relevant, as covered earlier: a notification should contain only what is needed to inform the user, not a full private message, and it should not expose sensitive details on a lock screen. Handling the tap should open the relevant part of the app, not just the homepage, so that a notification about a specific message, article, or task takes the user directly to that item, where they can act on it. A notification tap that opens the homepage, forcing the user to find the item themselves, wastes the notification's value and frustrates the user, especially when the notification was about something time-sensitive.
Tap handling also needs to consider app state. If the app is already open, tapping a notification should focus the existing window and navigate it to the relevant item, rather than opening a duplicate window. If the app is not open, tapping should open it at the relevant item, in its installed mode where applicable. If the relevant item no longer exists, or the user no longer has access to it, tapping should show a clear in-app message, rather than a broken page or a silent failure. Those cases — already open, not open, item gone — should all be tested, because a notification handler that only works when the app is closed will fail for users who keep the app open, and vice versa.
Notification actions, where supported, let a notification offer buttons, such as "Reply", "Mark as read", or "Dismiss", without opening the app. Actions should be few, clear, and safe: an action that deletes or sends something should be confirmed or reversible, not triggered by an accidental tap on a lock screen notification. Actions should also work offline where possible, by queueing the action for sync, rather than failing silently when offline. A notification action that promises to mark something as read, but does nothing offline, will mislead the user into thinking the item is handled when it is not.
Test push handling by sending notifications for different items, tapping them in different app states, using actions where available, and checking privacy by viewing notifications on a lock screen. Check that no sensitive content appears in a notification that could be seen by others, that taps land on the right item, and that actions work or queue correctly offline. Push notifications are a direct line to the user, even when the app is closed, and handling them with privacy, precision, and state-awareness is what makes that line trusted rather than intrusive. Also handle the case where a notification arrives while the user is already viewing the relevant item, so tapping it does not reload or disrupt work already in progress, but simply focuses the existing view. That small detail prevents a notification from feeling like an interruption even when the user chooses to act on it.
Check: Why should a notification tap open the specific item, not just the homepage?
Section 45 — App-like navigation and back behaviour
App-like navigation in a PWA means the user can move through the app, go back, and understand where they are, without relying on browser chrome that may not be present in standalone mode. That requires the app to provide its own navigation: a header with a back control where appropriate, a clear hierarchy of screens, and a way to return home from any deep screen. Back behaviour is particularly important: in a browser, the back button returns to the previous page, but in a standalone app, there may be no browser back button, so the app must provide one, and it must behave as the user expects, returning to the previous screen, not to the homepage or to an unrelated screen, and preserving state where possible, such as a form draft or a scroll position.
Navigation state should be preserved across app restarts and updates where possible. If a user closes an installed app on a deep page and reopens it, should it return to that page, to the homepage, or to a saved state? The answer depends on the app: a reading app might return to the last article, while a banking app might return to the homepage for security. Whatever the choice, it should be deliberate and consistent, not an accident of how the start URL is configured. The manifest's start URL controls where the app opens from the home screen icon, but in-app navigation and restoration after a restart are the app's own responsibility, and they should be designed, not left to defaults that may not match user expectations.
Deep linking and sharing also affect navigation. A link to a specific item in the app, opened from another app or from a notification, should land on that item, with a way back to the app's main navigation, not on an isolated page with no way out. A user who follows a deep link should be able to navigate to related content, to search, and to home, without having to close and reopen the app. That requires the app's routing to handle deep links as first-class navigation, with a back stack that makes sense, rather than as a one-off page load that strands the user.
Test navigation by installing the app, navigating deeply, using back controls, closing and reopening, following deep links and notification taps, and checking that each path returns the user to a sensible place, with state preserved where promised. Test also with a screen reader and a keyboard, to ensure navigation is announced and operable without a mouse. App-like navigation is not about hiding browser chrome; it is about replacing it with something equally clear and reliable, so that users in standalone mode can move as confidently as users in a browser.
Check: What back control must a standalone app provide itself, and why?
Section 46 — Loading states, skeletons and perceived performance
Loading states are what the user sees while content is being fetched, and they shape perceived performance more than actual load time alone. A blank page while content loads feels slow, even if the load is brief, because the user has no signal that work is happening or what will appear. A skeleton screen — a simplified, greyed-out version of the layout, showing where headings, images, and text will appear — feels faster, because the user sees the structure immediately and can anticipate the content, even before it arrives. A spinner alone, without structure, is better than blank, but worse than a skeleton, because it says "waiting" without saying "for what". The app shell, covered earlier, is the foundation for good loading states: it provides the structure that skeletons fill, and it loads instantly from cache on repeat visits.
Skeleton screens should match the content they precede, in layout and in approximate size, so that the transition from skeleton to content does not cause a jarring layout shift, where content jumps as it loads. A skeleton that is much smaller than the content it replaces will cause the page to grow suddenly, pushing content down and disorienting the user. A skeleton that matches the content's layout will be replaced smoothly, with content appearing in the places the user already expected. Skeletons should also be accessible: they should not be announced as content by screen readers, and they should not trap focus or be mistaken for interactive elements. Use aria-hidden or equivalent techniques for skeleton placeholders, and ensure that when content loads, focus and announcements reflect the real content, not the skeleton.
Loading states should also communicate progress honestly. A skeleton that shows for a long time without progress may be mistaken for a frozen app, especially on a slow connection. For longer loads, show a progress indicator or a message, such as "Loading your saved items…", and offer a way to retry or to view cached content where available, rather than leaving the user staring at a skeleton indefinitely. For failed loads, replace the skeleton with a clear error state, with a retry action, rather than leaving the skeleton visible as if loading were still happening. A loading state that never resolves is worse than an error, because it gives the user no action to take.
Test loading states on slow and offline connections, checking that skeletons appear promptly, match their content, transition smoothly, and are replaced by content, error, or cached fallback as appropriate. Test also with a screen reader, to ensure skeletons are not announced as content and that loaded content is announced correctly. Perceived performance is not deception; it is honest communication about what is happening and what will appear, and a PWA that communicates loading well will feel fast even when the network is slow, while one that shows blank pages will feel slow even when its network is fast.
Check: Why is a skeleton screen better than a blank page while content loads?
Section 47 — Testing on real devices and slow networks
Testing a PWA on a desktop browser alone is insufficient, because PWAs are used on phones, tablets, and desktops, on fast and slow networks, and in installed and browser modes, and each of those contexts reveals different problems. Real-device testing means installing the PWA on actual phones and tablets, using it on a real mobile network and on Wi-Fi, going offline in real places, and checking installation, icons, splash screens, offline use, notifications, and updates where they will actually be used. Emulators and developer tools simulations are useful for initial checks, but they do not reproduce launcher behaviour, push delivery, storage eviction, or network variability accurately, and they can hide problems that only appear on a real device with a real user's data and settings.
Slow-network testing is equally essential. A PWA that works on a fast office connection may fail on a slow mobile connection, where requests time out, caches are essential, and loading states matter. Test with throttled networks in developer tools as a starting point, and with real slow connections where possible, by using a device on a weak signal, or by throttling at the network level. Check that the app shell loads quickly, that content loads progressively, that timeouts are handled with fallbacks rather than hangs, and that offline indicators appear correctly. A PWA's performance and offline promises are most valuable on slow networks, and they must be tested there, not just assumed from fast-network results.
Test also with real user data and with low storage, low battery, and background restrictions, where platforms may limit service worker activity, background sync, or push delivery. A PWA that works with a fresh profile and ample storage may fail for a long-term user whose device is full, whose battery is low, or whose operating system restricts background work. Those conditions are normal for real users, and a PWA that degrades gracefully under them — by fetching on open when background sync did not run, by clearing old caches when storage is low, by showing a clear status when offline — will be trusted, while one that fails silently will not.
Record device and network test results, including device model, browser version, network condition, and installed versus browser mode, so that platform-specific failures can be reproduced and fixed. Testing on real devices and slow networks takes time, but it is the only way to know whether a PWA works as an app, not just as a website in a desktop browser. A PWA that has only been tested on a desktop, on a fast connection, in browser mode, has not been tested as a PWA at all. Include in the test record any workarounds used for a device or network condition, so that a later tester can reproduce the same conditions and confirm a fix, rather than testing under different conditions and concluding a problem is gone when it was only untested. Clear test records turn one-off device testing into repeatable evidence.
Check: Why is emulator testing alone not enough for a PWA?
Section 48 — PWA versus native app: choosing honestly
Choosing between a PWA and a native app is a product decision, not a technical loyalty test, made honestly from what the product needs, who its users are, and what the team can maintain. A PWA is a strong choice when the product is content, a tool, or a service that works well in a browser, when it needs to reach users quickly without app store approval, when it needs offline access and installation without maintaining several native codebases, and when its required device features are available on the web with acceptable support. It is a weaker choice when the product needs deep device integration — such as advanced camera processing, background processing with strict guarantees, or access to hardware that the web does not expose — when app store presence is essential for discovery or for payments, or when a platform's PWA support is too limited for the product's core features.
Cost and maintenance are part of the honest choice. A PWA's single codebase can be cheaper to build and maintain than separate native apps, but it still requires maintenance: service worker updates, cache management, platform testing, and keeping up with browser changes. A native app's multiple codebases cost more, but they offer deeper integration and app store distribution that a PWA cannot match. A team that chooses a PWA to save cost, but then discovers it needs native features, will spend more retrofitting or rebuilding than it saved. A team that chooses native apps for a simple content product will spend more than necessary for features its users do not need. The choice should therefore start from user needs and feature requirements, not from cost alone, and it should be revisited as those needs change.
User expectations also matter. Users on some platforms expect apps to be in the app store, to be found by searching the store, and to be installed through it, and a PWA that is only discoverable through the web may be missed by those users, even if it is technically installable. Users in other contexts, such as on the web-first markets or for internal tools, may prefer a web link to a store download, because it is faster and requires no account or approval. Research your users: where do they find apps, how do they install them, and what do they expect from an installed app? A PWA that is well promoted on the web, with clear install instructions, can succeed without a store, but it will not succeed by assuming users will discover installation on their own.
Finally, the choice is not always binary. Some products offer both: a PWA for broad web reach and offline use, and native apps for deep integration or store presence, sharing a backend and much of their logic. That hybrid approach costs more, but it serves both audiences well, where both matter. Whatever the choice, make it explicitly, document its reasons, and review it as platforms evolve, because PWA capabilities grow over time, and a product that needed native features last year may be well served by a PWA today, or vice versa. Choosing honestly, based on evidence rather than fashion, is what makes the choice defensible to users, to stakeholders, and to the team that must maintain it.
Check: When is a native app a better choice than a PWA?
Section 49 — PWA launch checklist
Launching a PWA requires checking more than its features; it requires checking its installability, offline behaviour, performance, updates, and platform support as a whole. A launch checklist should start with the basics: HTTPS with a valid certificate, a valid manifest with correct names, icons including maskable icons, start URL, display mode, and colours, and a service worker that registers safely, precaches the shell, and handles fetch events for navigation, assets, and data. Each of those should be verified in developer tools and on real devices, not just assumed from code, because a missing icon, a wrong scope, or a worker that fails to install will block installation silently, and users will not report it; they will simply not install.
The checklist should continue with offline and performance: does the app load its shell offline, does it show a helpful offline fallback for uncached pages, does it cache content the user visited, does it handle slow networks with loading states and timeouts, and does it update cleanly, with a prompt and without losing user work? Does it handle background sync and push, where those are offered, with permission earned, status communicated, and failures handled? Does it respect storage limits, clear old caches on update, and degrade gracefully when storage is evicted? Each item should have a test, on a real device, in installed and browser modes, offline and online, and after an update, because a checklist item that is ticked without a test is a hope, not a verification.
Accessibility, security, and privacy should also be on the checklist: is the installed app navigable by keyboard and screen reader, does it provide its own navigation in standalone mode, are sensitive requests excluded from caches, is scope correct, and are push payloads minimal and private? Are shortcuts, badges, and display modes correct on each platform, and are platform differences documented, so that users on a platform with limited PWA support are not promised features that do not work there? A launch checklist that omits accessibility or security will launch a PWA that is technically installable but not trustworthy, and trust, once lost through a privacy or accessibility failure, is hard to regain.
Finally, plan for after launch: monitor installability, worker updates, cache sizes, and user feedback in each platform, and schedule regular audits, as covered earlier. A PWA launch is not finished when the manifest and worker go live; it is finished when the app has been installed by real users, used offline, updated successfully, and reviewed for problems, and when the team has a process for maintaining it. A checklist that includes post-launch monitoring turns a launch from an event into a sustained practice, which is what a PWA, as a living app on users' devices, requires.
Check: Name three areas a PWA launch checklist must cover beyond basic features.
Section 50 — A PWA is a website that earns its place on the home screen
A progressive web app earns its place on a user's home screen, it does not demand it. It earns it by being a good website first — fast, accessible, responsive, and useful in a browser — and then by adding installation, offline support, and app-like conveniences that genuinely help the user, without breaking the browser experience for those who do not install. The techniques in this chapter — the manifest, icons, service worker lifecycle, caching strategies, offline fallbacks, background sync, push with permission, update prompts, and platform testing — are all ways of earning that place, from different angles. A PWA that earns it will be kept, used, and trusted. One that demands it, with early prompts, spammy notifications, or a broken offline experience, will be uninstalled or ignored, however technically complete it is.
Think about the PWAs you have kept on your own home screen, and why. They were probably useful, fast, and reliable, and they probably worked when you needed them, even offline, without surprising you with stale data, lost work, or intrusive messages. They probably updated without breaking your session, and they probably felt like they belonged on your device, not like a website pretending to be an app. Those qualities are not accidents; they are the result of the decisions this chapter has covered, made deliberately, tested on real devices, and maintained over time. The web's strength has always been its reach: anyone, anywhere, can open a link and use it. A PWA adds to that reach the persistence and reliability of an installed app, without losing the web's openness.
PWAs will keep evolving, as browsers add capabilities, platforms change their support, and new patterns for offline, background, and integration features emerge. The specific APIs and checks in this chapter will age, as all web APIs do, but the principles will not: build a good website first, add capabilities progressively, cache thoughtfully, communicate status honestly, respect permission and privacy, update without breaking sessions, and test on real devices in real conditions. If you hold to those principles, you can learn each new capability as it arrives and judge it by whether it helps the user, not by whether it is new or fashionable.
Before you move on, return to a website you have built or used, and ask whether it could earn a home screen place tomorrow. Does it have a manifest and icons? Does it work offline for its core tasks? Does it update cleanly? Does it ask for permission only when it has earned it? If any answer is no, you have found your next improvement, and it will matter more to real users than any new feature. That is the work of this chapter: not to make every website an app, but to make the websites that should be apps earn that place, one careful decision at a time.
Check: In one sentence, what must a website do to earn a place on the home screen?
Practical task
Build a small PWA from a simple site you already have: add a valid manifest with maskable icons, a service worker that precaches the app shell, serves cached content offline, shows an offline fallback page for uncached navigation, and uses a different caching strategy for stable assets versus changing data. Install it on a real phone if you can, test it offline, update it with a visible change, and check that the update prompt appears and that refreshing loads the new version without losing any draft or saved work.
Chapter questions
- A PWA shows old content for days after an update, even though the server has new content. Explain the caching and update failures that could cause this, and how you would fix each.
- A user fills a form offline, is told it was sent, and later discovers it never arrived. What went wrong in the offline and sync design, and how would you fix it?
- Design the caching strategy for a news PWA: which resources should be precached, which runtime-cached, and which strategy should each use, and why?
- A PWA asks for push permission on first visit and sends daily marketing notifications. Explain why users disable it, and design a permission and notification approach they would keep.
- A PWA works on Android but its install flow and offline behaviour confuse iOS users. What platform differences are involved, and how would you adapt the product and its instructions?