Making a website installable takes an afternoon: a manifest, a few icons, and HTTPS. Making it behave like an app takes much longer. The difference is almost entirely in the service worker, the script that sits between your app and the network. It decides what loads when the signal drops, what happens to a form submitted in a basement parking lot, and whether users get the new version after a deploy or stay stuck on last month. We covered when a PWA is the right choice compared with a native app in a separate article. This one is about building the offline and update parts properly, and about what changed for PWAs over the past year.
What Changed for PWAs in 2025 and 2026
Several changes in the past year and a half make PWAs easier to install, especially on iPhones. None of them change the service worker work described below.
| Platform | Change | What it means |
|---|---|---|
| iOS and iPadOS 26 | Every site added to the Home Screen opens as a web app by default. A manifest is no longer required, and WebKit says there are now no installability requirements in Safari. | Installing still happens through the Share menu, but any site becomes an app-like icon. Your manifest still controls the name, icons, and display mode when it exists. |
| iOS and iPadOS 18.4 | Declarative Web Push: a push message can describe the notification itself, without a service worker having to build it. | Push on Apple devices becomes more reliable. On iPhone, web push still targets web apps added to the Home Screen. |
| Firefox 143 on Windows | Firefox can install sites as web apps that open in their own taskbar window. | The first time in years that Firefox desktop offers installation. Not yet available on macOS or Linux. |
| Chrome | The installability criteria list HTTPS, a manifest with a name, 192 px and 512 px icons, start_url, and a standalone-type display mode. A service worker is no longer listed. | You can get the install prompt without offline support, so a service worker is now something you add for its features, not to unlock installation. |
| Lighthouse 12 | The PWA category was removed in 2024. Some checks moved to Best Practices. | There is no longer a PWA score to aim for. You need your own checklist and tests. |
How a Service Worker Works
A service worker is a JavaScript file that the browser runs separately from your pages. It has no access to the DOM. Its main job is to intercept every network request from pages within its scope and decide how to answer: from the network, from a cache, or by building a response itself. It only runs on HTTPS, with localhost as the exception for development.
- 1Register: the page calls navigator.serviceWorker.register with the script URL. The location of the file sets the default scope, so a worker at /sw.js controls the whole site.
- 2Install: the browser downloads the script and fires the install event. This is where you usually pre-cache the app shell: the main HTML, CSS, JavaScript, and icons.
- 3Wait: if an older service worker is still controlling open tabs, the new one waits. It only takes over once every tab using the old one is closed.
- 4Activate: the new worker takes control and fires the activate event. This is where you delete caches from older versions.
- 5Fetch: from now on, every request from controlled pages passes through the fetch event, where your caching strategy runs.
The browser checks for a new version of the service worker script on navigation, and at least once every 24 hours. If even one byte has changed, the install cycle starts again. That waiting step is the source of the most common PWA bug, which we come back to below.
Choosing a Caching Strategy per Type of Content
There is no single right caching strategy for a whole app. Each type of request needs its own, and most bugs come from applying one strategy to everything.
| Strategy | How it works | Good for |
|---|---|---|
| Cache first | Answer from the cache; go to the network only if the item is missing. | Files with a version or hash in the name: JS and CSS bundles, fonts, icons. |
| Network first | Try the network; fall back to the cache if it fails or takes too long. | Pages and API data that should be fresh but are still useful when slightly old, such as a product list or order history. |
| Stale while revalidate | Answer from the cache immediately and update the cache from the network in the background. | Data where showing something instantly matters more than freshness: avatars, catalog images, reference data. |
| Network only | Always go to the network, never cache. | Login, payment, checkout, and anything that writes data. |
| Cache only | Answer only from what was pre-cached at install. | The offline fallback page and the core app shell. |
Add a timeout to network first. Without one, a request on a weak 3G signal can hang for 30 seconds before the cache fallback kicks in, which feels worse than being fully offline. Three to five seconds is a reasonable starting point for pages.
What You Should Not Cache
- API responses with personal data on devices that people share, such as a store tablet or a family phone. Once cached, they can be read after logout unless you clear the cache on logout, which you should do anyway.
- POST, PUT, and DELETE requests. The Cache API only stores GET responses; offline writes need a different mechanism, described below.
- Responses from other domains without CORS, known as opaque responses. The service worker cannot check whether they succeeded, so you may cache an error page, and Chrome counts each one as around 7 MB against the storage quota.
- The service worker file itself. Serve sw.js with Cache-Control: no-cache so the browser always checks the latest version.
- Everything on the site by default. Pre-caching hundreds of megabytes of images on the first visit uses up the data quota of users on prepaid packages, and they will not thank you for it.
Updates: The Bug That Keeps Users on an Old Version
The most common complaint after launching a PWA is that users do not see the fix that was deployed this morning. The cause is usually the waiting step. The new service worker installs and then waits, because the app is still open somewhere, and on phones the app is rarely closed completely. Meanwhile the old worker keeps serving the old cached HTML, which loads the old JavaScript.
The tempting fix is to call skipWaiting in the install event so the new worker takes over immediately. That works, but it has a cost: a page that is already open was loaded by the old version and may now request files that the new worker has already deleted, or call an API that has changed. Errors appear that are hard to reproduce. A safer pattern:
- 1Detect a waiting service worker from the page through the registration updatefound event and the waiting property.
- 2Show a clear banner such as "A new version is available" with a Refresh button.
- 3When the user taps it, send a message to the waiting worker so it calls skipWaiting, then reload the page once the controllerchange event fires.
- 4For critical fixes, such as a security issue or a broken payment flow, you can force the update, but do it at a safe moment, for example on the next navigation, not in the middle of filling in a form.
- 5Keep the backend API backward compatible for at least one release, because some users will still be running the previous frontend for a few days.
Use file names with a content hash for every bundle, which Next.js, Vite, and similar tools already do, and delete old caches in the activate event based on a version name. Without that cleanup, each release adds another copy of the app to the device.
Offline Forms and Data
Showing cached pages offline is the easy half. The harder half is accepting input while offline, such as an order from a salesperson in an area with no signal or an attendance check-in at a construction site. The pattern that works:
- Store each submission in IndexedDB as an outbox entry, with a unique ID generated on the device. localStorage is too small and blocks the main thread.
- Show the status clearly: saved on this device, sending, sent, or failed. Users who cannot see the status will submit the same order three times.
- Send the outbox when the connection returns. Chrome and Edge on Android support Background Sync, which retries even after the app is closed. Safari and Firefox do not, so also retry when the app opens and on the online event.
- Make the server idempotent: use the ID from the device as an idempotency key so a submission that is sent twice is saved only once.
- Decide in advance what happens with conflicts. If the stock changed while the order was offline, does the server reject it, adjust it, or flag it for review? This is a business rule, not a technical one, so agree on it with the users.
If the app needs a full local database with two-way sync, hundreds of records edited offline every day, and complex conflict rules, consider a sync engine or a native app. A PWA can do it, but the effort grows quickly.
Storage Limits and Data Deletion
Browsers give much more space than most people assume. According to WebKit, Safari allows one origin up to 60 percent of the disk in the browser app, and Chrome uses a similar limit. The bigger risk is not the quota but automatic deletion.
- Safari deletes all script-writable storage for a site, including IndexedDB and service worker caches, after seven days of Safari use without the user interacting with that site. WebKit has said that web apps added to the Home Screen have their own counter and are not expected to lose first-party data this way.
- When the device runs low on space, browsers evict data from the least recently used sites first.
- Call navigator.storage.persist() to ask for persistent storage. Chrome grants it based on signals such as installation and engagement, and WebKit says it considers whether the site runs as a Home Screen web app.
- Never treat the device as the only copy of important data. Send it to the server as soon as possible and treat local storage as a cache and an outbox.
Tools: Write It by Hand or Use a Library
A service worker for push notifications only is short enough to write by hand, and the Next.js documentation has an example. Once routing and caching strategies come in, a library saves time. Workbox from Google has been the standard for years. Serwist grew out of Workbox and is the option the Next.js documentation points to for offline caching, with examples for both Turbopack and webpack. Next.js also has an experimental useOffline hook for connectivity-aware UI and automatic retries of failed navigations and Server Actions, which covers part of the problem without a full service worker.
Testing Without Fooling Yourself
- In Chrome DevTools, the Application panel shows the service worker, caches, and storage. Turn off "Update on reload" after development, or you will never see the update bug your users see.
- Test offline with the Offline option in the Network panel, and also with slow network throttling. Weak signal behaves differently from no signal.
- Test on a real iPhone with the site added to the Home Screen, through Safari Web Inspector on a Mac. Safari 26 can inspect service workers of Home Screen web apps automatically.
- Test the update path: install version 1, deploy version 2, and open the app the way a user would, not with a hard refresh.
- Since Lighthouse no longer has a PWA category, write a short checklist for the project: installs, opens offline, submits offline and syncs, updates after deploy, clears data on logout.
A Short Checklist
- 1Manifest with name, short_name, start_url, display set to standalone, and 192 px, 512 px, and maskable icons.
- 2Pre-cache only the app shell and an offline fallback page.
- 3One caching strategy per type of request, with a timeout on network first.
- 4No caching for login, payment, writes, or personal data on shared devices, and clear caches on logout.
- 5sw.js served with no-cache, old caches deleted on activate, and an update banner instead of a silent skipWaiting.
- 6Offline input stored in IndexedDB, sent with an idempotency key, and retried on open and on the online event.
- 7Persistent storage requested, and important data sent to the server as soon as possible.
- 8Tested on a mid-range Android phone and on an iPhone with the app added to the Home Screen.
A PWA is judged in the worst moment: a weak signal, a deploy an hour ago, and a user who needs to submit something now.
Key takeaways
- Installation is easier than ever: iOS 26 opens any site added to the Home Screen as a web app, Firefox 143 installs web apps on Windows, and Chrome no longer lists a service worker as an install requirement.
- The service worker decides how the app behaves offline and during updates. Choose a caching strategy per type of request rather than one for everything.
- Never cache login, payment, writes, opaque responses, or the sw.js file itself, and clear cached personal data on logout.
- The new version waits until old tabs close. Show an update banner instead of forcing skipWaiting, and keep the API backward compatible for one release.
- Offline input belongs in an IndexedDB outbox, sent with an idempotency key; Background Sync only exists in Chromium browsers.
- Lighthouse no longer scores PWAs, so test installation, offline use, sync, and updates yourself on real Android and iPhone devices.


