PWA Deployment Guide: Serving, Service Worker Updates, and What Breaks in Production
Most PWA guides stop at the manifest. Deployment is where the interesting failures live: an app that passed every local check, shipped fine, and then served a stale shell to returning users for three weeks because one cache header was wrong.
Deploying a PWA is a normal web deploy plus one extra contract — the browser has to be able to verify installability at your production origin, and your service worker has to be able to hand users new code after it has already taken control. This guide covers the serving requirements, the service worker lifecycle as it actually behaves in production, the update model, and the deployment failures that repeat.
If you are still deciding whether the field set is right, the field-by-field reference is in PWA manifest requirements. This guide assumes the manifest is correct and asks what happens when you push it live.
The serving requirements are non-negotiable
Four things have to be true at the production origin, not just in your local build:
HTTPS, everywhere on the path. Service worker registration requires a secure context. localhost is exempted, which is exactly why a PWA can work perfectly in development and register nothing in production. Mixed content counts — one asset over plain HTTP on the install path can be enough.
The manifest is reachable and served as JSON. A manifest returned with the wrong content type, or behind an auth redirect, fails silently. The install criteria simply are not met, and no error appears in the page.
The service worker file is served from the scope it needs to control. A worker served from a subdirectory cannot control paths above it. Serving it at the origin root is the default that avoids the whole class of problem.
start_url resolves without a redirect. If it redirects — country routing, a trailing-slash normalization, an auth bounce — the installed app can open somewhere other than where you intended, and installability checks can fail outright.
The service worker lifecycle is the deployment model
This is the part that behaves differently from every other deploy you have done. A service worker does not replace itself the moment you upload a new one.
The sequence is: the browser fetches the new worker file, compares it byte-for-byte with the installed one, and if it differs, installs the new one. The new worker then waits. It does not activate while the old one still controls an open page. It takes over only after every tab under its scope has closed — which, for a PWA users keep on the home screen, can be a very long time.
Three consequences fall out of this, and all three surprise people on their first production deploy:
- Your new code is not live when your deploy is green. It is installed and waiting. The dashboard says shipped; users are still running the old shell.
- A hard refresh does not fix it. Reloading a controlled page keeps the same controller. The old worker stays in charge.
skipWaitingis not a free fix. Calling it activates the new worker immediately, but the page currently open was loaded with the old worker's assets. Mixing a new worker with an old page's chunk expectations is how you get a blank screen or a failed lazy import in production. If you use it, pair it with a prompt or a controlled reload so the page and the worker change together.
The honest options are: prompt the user to reload when a new worker is waiting, or accept the natural handover and design so that old and new can coexist for a while. Silently calling skipWaiting on every deploy is the option that looks cleanest and breaks most often.
Cache headers are the single most expensive detail
The rule that prevents the worst outcome: the service worker file itself must not be cached for long by the HTTP layer.
If your CDN or origin serves the worker with a long max-age, the browser will not fetch a fresh copy for that duration. It cannot compare bytes, so it never learns there is an update, so nothing you deploy reaches installed users until that TTL expires. This is the mechanism behind almost every "our PWA is frozen on an old version" report.
Serve the worker with no-cache — or a very short max-age — and let the byte comparison do its job. Your hashed static assets can and should be cached aggressively; the worker file is the one exception, because it is the thing that has to change to change everything else.
The same logic applies to the manifest and to index.html on a shell-cached app: they are the entry points that tell the browser what everything else is.
Choosing a caching strategy without painting yourself in
Cache strategy is a per-resource decision, not an app-wide one:
- Hashed build assets — cache-first is correct. The filename changes when the content changes, so staleness cannot happen.
- The app shell / HTML entry — network-first with a cache fallback, or stale-while-revalidate. Cache-first here is what strands users on an old build.
- API responses — network-first for anything that has to be current; stale-while-revalidate only where showing slightly old data beats showing a spinner.
- User-specific data — be careful. A cache keyed without the user identity will serve one account's data to the next person on a shared device.
Version your cache names, and delete old versions in the worker's activate step. Caches that accumulate across deploys eventually hit storage quota, and quota eviction is not something you get to control the timing of.
Verify at the production origin, not on localhost
The gap between "works locally" and "installable in production" is where most of the wasted time goes. Before you call a deploy done:
- Open the deployed origin in a fresh profile with no prior registration and confirm the worker registers and reaches activated.
- Confirm the install path is offered — the criteria are evaluated against production, and
localhostexemptions do not apply there. - Go offline and reload. An app that shows the browser's offline page has a worker that registered but is not serving the shell.
- Check that
start_urlreturns 200 directly, with no redirect hop. - Deploy a trivial visible change and confirm it reaches an already-installed instance. This is the only test that actually exercises your update path, and it is the one most teams skip.
That last item is worth stating plainly: if you have never verified that a second deploy reaches an existing install, you do not yet know whether your update mechanism works. You only know that your first deploy worked.
Deployment failures that repeat
Scope mismatch. The worker is served from a build output directory and silently controls only that subtree. Pages above it are uncontrolled, so half the app is offline-capable and half is not.
A registration that never runs. The registration call sits in a script that fails earlier, or behind a route that most users never hit. Nothing errors; the app simply is not a PWA for those users.
Icons that do not meet the required sizes. Installability fails, usually with no visible symptom other than the absence of an install path.
The old worker on a changed domain. Registrations are per origin. Moving domains means every installed user is on the old origin with the old worker, and they will not follow automatically.
Caching the response of a redirect. Storing a redirected response and replaying it later produces navigation behavior that is very hard to reproduce on a developer machine.
No rollback plan. Rolling back the server is not enough — the previous worker has to be the one that wins the byte comparison, which means your rollback must produce a worker file that differs from the bad one. Redeploying an identical earlier artifact does exactly that; assuming a CDN purge alone will do it does not.
Where this fits in the distribution picture
Deployment is also the reason teams choose this path in the first place. There is no submission step and no review queue between a finished build and users having it — you deploy, and the update propagates on the lifecycle described above. That property is the actual operational difference from store distribution, more than any capability comparison; the fuller trade-off is in PWA vs APK, and the conversion side of the install flow is covered in PWA landing page.
ROIBest builds Android PWA distribution for teams that want that install path in production without assembling the serving, install-prompt, and update mechanics themselves.
Frequently asked questions
Why does my PWA still show the old version after deploying?
Almost always one of two causes: the new service worker is installed but waiting because an old tab still controls the scope, or the worker file itself is being served with a long cache lifetime so the browser has not fetched the new bytes. Check the HTTP cache headers on the worker file first.
Do I need a service worker for a PWA to be installable?
Installability is evaluated against the manifest and the secure context. A service worker is what makes the app work offline and gives you the update mechanism, so a deployment without one is not meaningfully a PWA even where a prompt appears.
Where should the service worker file live?
At the origin root, unless you have a specific reason otherwise. Its location determines its scope, and a worker in a subdirectory cannot control pages above it.
Is it safe to call skipWaiting on every deploy?
It is the most common cause of blank screens after a deploy. The currently open page was built against the old worker's assets. If you use it, trigger a page reload at the same time so the document and the worker change together.
How do I roll back a bad PWA deploy?
Redeploy the previous artifact so the worker file's bytes differ from the bad version, which is what triggers a fresh install. Purging a CDN alone does not guarantee installed clients re-evaluate, and it does not resolve a worker that is already active.
Can I host a PWA on static hosting?
Yes. The requirements are HTTPS, correct content types, and control over cache headers for the worker file. Any static host that gives you header control is sufficient.
The short version
Serve over HTTPS, keep the worker at the root, make sure start_url does not redirect, and never let the HTTP layer cache the worker file. Understand that a green deploy means "installed and waiting," not "live." Pick cache strategies per resource, version your cache names, and clean up on activate. Then verify at the production origin with a fresh profile — and verify a second deploy reaches an existing install, because that is the only test that proves your update path works at all.


