Service Worker Guide¶
pramnos init --service-worker=y writes <web-root>/sw.js and the two lines that
register it. It caches static assets in the browser — stylesheets, scripts, fonts
and images — so a repeat visit pays for the HTML and nothing else.
It is off by default, and the rest of this page is mostly about the things it deliberately will not do.
Why it is opt-in¶
A service worker is the most persistent thing an application can install on somebody else's machine. It keeps itself alive across reloads, so a mistake in one is not corrected by the next deployment the way a mistake in a page is — the fixed page has to get past the worker first. Shipping that to every scaffolded project by default would be handing a loaded mechanism to people who did not ask for one.
What makes it safe to say yes to is the boundary below.
What it caches, and what it refuses to¶
| Method | GET only |
| Origin | same-origin only |
| Paths | anything ending .css .js .mjs .woff .woff2 .ttf .otf .eot .png .jpg .jpeg .gif .svg .webp .avif .ico |
| A page (a navigation) | from the network — the navigation-preload answer when the browser started one — and not stored, unless the registration names public pages to keep (Caching HTML as well); with no network at all, the offline page |
admin/ and api/ under the scope |
not intercepted at all, assets and pages alike — see Paths left to the browser |
| Everything else | not intercepted at all — no respondWith, so the browser behaves as if there were no worker |
HTML is not stored by default. A worker that caches HTML the usual way needs a list of URLs never to cache — the signed-in pages, the checkout, the profile editor — and that list is maintained by hand, one line at a time:
if (request.url.includes('/admin')) { return; }
if (request.url.includes('/account/profile')) { return; }
// …one more line for every private page the application ever adds
Every page added to the application is a chance to forget a line, and the consequence is one visitor's personal page stored in another's browser — where nothing on the server can reach it. It is the same failure the page cache protects against with its bypass rules, except that those rules cover a store the application owns and can purge.
A worker that never sees HTML cannot make that mistake, and needs no list. When pages are worth keeping, the scaffolded worker keeps only what the server marks public and only while nobody is signed in — an allow-list the server decides, never a deny-list kept in the worker. See Caching HTML as well.
Two strategies, chosen by whether a URL can change meaning¶
Immutable — cache-first, never revalidated. A new version is a new path, so there is nothing to check:
assets/vendor/<lib>/<version>/…— the version is in the directoryassets/spa/…— Vite content-hashes every filename
Everything else — stale-while-revalidate. The cached copy is served and a fresh one is fetched behind it. The reader may see one stale paint; the next load is correct.
assets/css/style.css is the case that matters: its URL never changes, so cache-first
there would be stale forever — and if the stored copy were an error page, a
Maintenance Mode response say, the visitor would keep getting it through hard reloads.
Which is why only a successful response is stored: response.ok and not opaque, so a
redirect, a 404, a 500 and an unreadable cross-origin response are all refused.
There is no Set-Cookie check, and there cannot be
PageCache::store() refuses a response carrying Set-Cookie, for good reasons. The
same check here would be theatre: Set-Cookie is a forbidden response header, so
fetch() never exposes it and the test would always pass. What makes it unnecessary
is the path filter — a stylesheet is not answering a particular visitor.
There is no cache version to bump¶
The usual pattern is a version prefix on the cache name, bumped by hand at deploy time. This has none, and that is deliberate: a version prefix only purges the caches that carry it. A worker with several caches, one of them unversioned, keeps the unversioned one across every bump — the activation sweep deletes caches not in the current list, and an unchanging name is always in it — so one bad response outlives every reload and every version bump.
Nothing here needs a version. Immutable entries stay valid by definition and everything else revalidates itself, so a bump would be repairing no state. What bounds the cache instead is a cap on the number of entries, enforced when writing:
Raise it if the application has more assets than that in play at once. It is FIFO, not LRU — the oldest writes go first regardless of how often they are read, because a real LRU needs an access log of its own and a static asset cache does not earn one.
A timer inside a service worker does not run
A browser terminates an idle service worker, so a cleanup on a setInterval of
hours essentially never fires. Anything periodic has to happen on an event the
worker is woken for — which, here, is a cache write.
Where it lives, and why that is not cosmetic¶
The file is at the web root, not under assets/. A service worker's default scope
is the directory it is served from, so one at assets/sw.js could only ever intercept
requests for assets/…. Its own path decides what it is allowed to see.
The registration is built in PHP rather than shipped as a static file, for the same reason:
An application served from a subdirectory registers at /sub/sw.js, and its scope
follows. A hard-coded /sw.js would 404 — or, if something answers it, register a
worker scoped above the application.
It is emitted in two places, because a project can have both kinds of page:
- the theme footer, for MVC pages — where
Html::render()stamps the CSP nonce onto it like any other inline script; - the SPA shell, which emits its own HTML and never sees the theme footer. That copy
carries no nonce and cannot: the shell renders nothing through the document layer
and boots no application, so neither the nonce injection nor the policy applies. It is
in the same position as the
window.__PRAMNOS__script already beside it. An application putting a nonce policy in front of its SPA has to account for both.
The same reason changes the base URL in the shell's copy:
sURL is defined while the application boots, and the shell requires only the
autoloader — the constant does not exist there. $siteUrl is the shell's own getUrl()
call, made a few lines higher for the API key, and it is the same value, so the
subdirectory case is still handled. If you have customised the shell, keep the
assignment above the registration.
CSP: worker-src has to allow it¶
worker-src governs Worker, SharedWorker and the service-worker script. A policy
of worker-src 'none' refuses every registration — the register() promise rejects and
nothing installs.
The framework's default is worker-src 'self' blob:, and it reads the csp block like
every directive around it:
// app/app.php — only if the worker is served from somewhere else, which is unusual
'csp' => ['worker-src' => ['https://cdn.example']],
'self' is the tightest value that works for the service worker: a browser will not
accept a cross-origin service-worker script in the first place. blob: is there for
workers a page builds from a Blob — pf-humancheck.js builds its solver that way. Neither
gives up much over 'none': both need a script already running on your origin, at which
point script-src 'self' and the nonce have already been defeated.
If the worker is not registering, read the console. The registration reports a
rejected register() with console.warn, and a CSP refusal names the directive that
refused it. A refused registration is a misconfiguration somebody can fix, so it is
logged rather than swallowed.
Two other reasons registration silently does nothing, neither of them CSP:
navigator.serviceWorkeris undefined outside a secure context.https://…andhttp://localhostare secure;http://192.168.…and a plainhttp://hostname are not, so the guard short-circuits and nothing is logged at all.- The scope. A worker at
/sub/sw.jscontrols/sub/…and nothing above it, so a page at/is not controlled even though registration succeeded.
Removing it, or clearing it¶
Because HTML is never cached, a bad deploy of sw.js cannot lock anyone out of the
site — the worst case is a stale stylesheet that fixes itself on the next load. The
recovery paths still exist, because a worker keeps itself alive and "deploy a fix" is
not a recovery mechanism for the worker itself.
From any page on the site:
// Empty the cache, keep the worker
navigator.serviceWorker.controller.postMessage({command: 'purge'});
// Remove the worker and its cache
navigator.serviceWorker.controller.postMessage({command: 'unregister'});
Or, without the worker's cooperation:
To stop shipping it, delete <web-root>/sw.js and the registration lines. Note that
deleting the file alone is not enough for visitors who already have it: a 404 on the
worker script causes the browser to unregister it, but only when it next checks — which
is on navigation, and up to 24 hours later. Sending an explicit unregister from the
page is what makes it immediate.
The offline page, and an image with no network¶
A navigation that fails for want of a network shows the offline page, one static file. That
is offline.html beside the worker, or the page named in the registration:
It is stored on install, in a cache of its own (<app>-assets-offline) that the asset cap never
evicts. Without such a file there is no fallback, and the browser shows its own error. Pages
themselves are still never stored. The offline page stands in for every page; no page stands in
for itself.
An image with no cached copy and no network is drawn as a grey box that says "offline", rather than as a broken image.
Paths left to the browser¶
A worker registered at the site root controls every page under it, the administration area and
the API included, and a scope cannot leave a sub-path out. Served stale-while-revalidate, an
administrator gets last release's admin.js beside this release's HTML once after every deploy,
and that can break the page.
So the worker leaves some path prefixes entirely to the browser, before anything else, with no
asset cached and no offline page. By default these are admin/ and api/, relative to the
worker's scope. admin/ also covers /admin itself, and it is a prefix, so /administrators is
not covered. An area under another prefix names its own in the registration:
navigator.serviceWorker.register('/sw.js?skip=backoffice/,api/'); // other prefixes
navigator.serviceWorker.register('/sw.js?skip='); // none
navigator.serviceWorker.register('/sw.js?offline=errors/offline.html&skip=admin/,api/');
A skip list replaces the default rather than adding to it, so name every prefix you want.
What cannot change is served from the cache without asking the network: assets/vendor/ and
the SPA build by default. The registration adds more with immutable. Each entry is a path under
the scope, or ?name for an address carrying that query parameter. A ?v= stamped on every
deploy makes each version a new address that never changes in place:
The page decides, not only the address asked for. A root asset an admin page loads, such as
assets/js/pf-utils.js or a font from its stylesheet, is left alone too: by the request's
referrer, or by the page it is for (clients.get(clientId)). A navigation into a skipped area
still takes its navigation-preload answer, because leaving it unused makes the browser request
the page a second time.
Navigation preload¶
The worker switches on navigation preload when it activates. The browser then starts a page's
request while the worker is still starting, and the worker answers with that response
(event.preloadResponse). Without it, the worker's boot was added to every navigation: about
50 ms on a desktop and 250 ms or more on a phone. Where it is unsupported, the worker fetches as
before.
The worker's address¶
Register it through \Pramnos\Push\ServiceWorker::url(), which init writes into the themes. It
adds ?v= with the worker's modification time and then the parameters the worker reads:
navigator.serviceWorker.register('<?php echo \Pramnos\Push\ServiceWorker::url('sw.js', ['skip' => 'admin/,api/']); ?>');
A deploy is then a new address, with no version bumped by hand. Serve the worker itself with
Cache-Control: no-cache, or a CDN goes on answering with the old one. push:setup checks the
header (its Worker caching step).
Replacing a worker of your own¶
A worker this one replaces has usually left caches behind, the hand-versioned
v1.32::static, v1.32::pages and v1.32::images. On activation this worker deletes every
cache matching LEGACY_CACHES (/::(?:static|pages|images)$/). Add a pattern there if yours
had names of another shape.
That matters for two reasons:
- Privacy. A
pagescache holds signed-in pages. - Stale copies.
caches.match()reads every cache on the origin, so an older worker's stale copy of a stylesheet would be served first. This worker reads only its own cache.
Messages a page can send it:
| Message | What the worker does |
|---|---|
{command: 'purge'} |
Empties its asset cache |
{command: 'unregister'} |
Unregisters and empties the cache |
{command: 'broadcast', message} |
Sends {command: 'broadcastOnRequest', message} to every page it controls |
Its own errors are reported to ClientErrors as kind worker, beside the pages' errors. A
page cannot see what fails inside the worker. See
Errors in the browser. Each message is sent
once in the worker's life, at most five reports a minute in all, and without cookies, so a report
never waits on the session lock.
A broken CacheStorage. Some browser profiles fail every caches.open(). The worker reports
that once and is then network-only for the rest of its life: it leaves requests to the browser and
never retries the cache. Storing a copy is best effort everywhere, so a write that fails costs the
copy, never the response.
Caching HTML as well¶
Off by default. The registration turns it on with rules that are an allow-list of public paths, never a deny-list of private ones:
navigator.serviceWorker.register('<?php echo \Pramnos\Push\ServiceWorker::url('sw.js', [
'pages' => json_encode([
['match' => '^/(el|en)/jobs', 'strategy' => 'network-first', 'timeout' => 1500, 'maxAge' => 3600],
['match' => '^/(el|en)/', 'strategy' => 'swr', 'maxAge' => 28800],
]),
'maxPages' => 30,
'marker' => 'logged_in',
]); ?>');
swranswers from the kept copy at once and asks the network behind it. When the ETag changed, it posts{type: 'page-updated', url}onBroadcastChannel('pramnos-sw')for a page that wants to say so.network-firstasks the network, and answers with the kept copy only when the network is slower thantimeoutor fails.maxAgeis how old a kept copy may be. An older one is not used.- Where they are kept. The pages go to their own cache,
<slug>-assets-pages, at mostmaxPagesof them.
Every answer says where it came from: Server-Timing: sw;desc=cache, network or preload.
What is kept. Only a GET answered 200 by the site and marked Pramnos-Page: public, which the
page cache sends with pageSignal on, and only while nobody is signed in. The worker reads the
login marker with
cookieStore; a browser without cookieStore keeps no page at all. So the page cache's allow-list
is the worker's too: what the server would not share, the browser does not keep.
When the kept pages go:
- any same-origin request that is not a GET;
- a navigation answered Pramnos-Page: private;
- any response carrying Pramnos-SW: purge-pages;
- the message {command: 'purge-pages'}.
Do not use Clear-Site-Data: "storage" for this. It unregisters the worker and drops the push
subscription with it.
It also receives push notifications¶
The scaffolded worker carries the three handlers web push needs — push,
notificationclick and pushsubscriptionchange — because push is delivered to a
service worker, and a site without one cannot receive a notification at all.
They cost nothing on a site that never sends one: push fires only when something is
pushed. If you wrote your own worker, the push guide is where
those handlers are, and pushsubscriptionchange is the one worth copying even if you
skip the others — without it, a browser that rotates its keys stops receiving
notifications permanently and nobody is told.
Two payload fields change what they do. data.ack is a URL the worker POSTs to as the push
arrives — a receipt, used by sign-in approvals so the waiting page can say "delivered". And
data.open: true makes a tap open url in a new window instead of focusing a tab that is
already open. The worker's own requests (push/key, push/subscribe, push/unsubscribe) are
resolved against its registration scope, so a site installed in a folder works.
An application's sw.js is its own file, and the framework never rewrites it. If yours
lacks these handlers or fields, copy the Web push block of
scaffolding/templates/service-worker.js.stub over yours.
See also¶
- Web Push Guide — notifications, and what the worker does with them
- Page Cache Guide — the server-side half, including what a hit tells the browser
- Console Commands — what
initwrites - Application Styles — the SPA shell