diff --git a/frontend/src/app.html b/frontend/src/app.html
index 6cdf730..3079735 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -38,6 +38,19 @@
// screen. Show a calm recovery card if the app hasn't mounted, and reload
// once on a chunk/preload failure (e.g. a just-deployed hashed chunk).
(function () {
+ // The service worker was removed (it risked first loads). Actively
+ // unregister any worker a returning visitor still has + wipe its caches,
+ // so nobody stays stuck on the old boot path. Runs on every load; cheap.
+ if ('serviceWorker' in navigator) {
+ try {
+ navigator.serviceWorker.getRegistrations()
+ .then(function (rs) { rs.forEach(function (r) { r.unregister(); }); })
+ .catch(function () {});
+ if (self.caches && caches.keys) {
+ caches.keys().then(function (ks) { ks.forEach(function (k) { caches.delete(k); }); }).catch(function () {});
+ }
+ } catch (e) { /* best effort */ }
+ }
var sent = false;
function report(reason) {
if (sent) return; sent = true; // one beacon per page
diff --git a/frontend/src/service-worker.js b/frontend/src/service-worker.js
deleted file mode 100644
index 0f233e7..0000000
--- a/frontend/src/service-worker.js
+++ /dev/null
@@ -1,114 +0,0 @@
-///
-// Calm, lightweight service worker. It does NOT bulk-precache on install — the
-// browser already caches the year-immutable assets on its own, and a cold
-// precache storm right after a deploy hammers the (residential) origin and slows
-// first loads. Instead: cache the shell for an offline fallback, and cache other
-// assets lazily as they're actually used. Live data (API + server-rendered pages)
-// is always fetched fresh.
-import { version } from '$service-worker';
-
-const CACHE = `upbeat-${version}`;
-// How long a navigation may wait on the network before the cached shell is
-// served instead. Long enough for a healthy fetch, short enough that a stalled
-// cellular/origin hop never reads as a broken site.
-const NAV_TIMEOUT_MS = 2500;
-
-// Paths the FastAPI server owns — the SW must NOT intercept or cache these.
-function isServerPath(p) {
- if (p.startsWith('/api/') || p.startsWith('/a/') || p.startsWith('/docs')) return true;
- return p === '/openapi.json' || p === '/healthz' || p === '/today' || p === '/sitemap.xml';
-}
-
-// Mutable files Caddy serves with `no-cache` — the browser's HTTP cache
-// revalidates these correctly, but SW cache-as-you-go would pin them until the
-// next SW version and silently defeat that policy. version.json is the big one
-// (it's how the app detects a new deploy); stale word lists could drift from
-// the server's validated answer pool. Let the network/browser cache own them.
-function isMutablePath(p) {
- return (
- p === '/service-worker.js' ||
- p === '/_app/version.json' ||
- p === '/manifest.webmanifest' ||
- p === '/words-5.json' ||
- p === '/words-6.json' ||
- p === '/favicon.svg' ||
- p === '/logo.svg' ||
- p === '/logo-email.png' ||
- p.startsWith('/icon-')
- );
-}
-
-self.addEventListener('install', (event) => {
- // Best-effort: grab the app shell as an offline fallback. No bulk precache.
- // Deliberately NO skipWaiting(): a new worker installs quietly and waits, so
- // it never seizes a page that's mid-boot. It takes control on the next
- // navigation, when the old worker has no clients — the post-deploy first-load
- // then completes under the stable old worker (with its cache intact) against
- // the warmed edge, instead of having its cache yanked mid-load.
- event.waitUntil(caches.open(CACHE).then((c) => c.add('/').catch(() => {})));
-});
-
-self.addEventListener('activate', (event) => {
- // Runs only when this worker actually activates (next navigation, never
- // mid-boot), so deleting old version caches here is safe — and old immutable
- // chunks live on at the origin for a 14-day grace window regardless. No
- // clients.claim(): pages adopt this worker on their own next navigation.
- event.waitUntil(
- caches
- .keys()
- .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
- );
-});
-
-self.addEventListener('fetch', (event) => {
- const { request } = event;
- if (request.method !== 'GET') return;
- const url = new URL(request.url);
- if (url.origin !== location.origin) return;
- if (isServerPath(url.pathname) || isMutablePath(url.pathname)) return; // network/browser cache owns these
-
- // Navigations: network-first, but a SLOW network must not mean a white screen —
- // "slow" and "failed" both fall back to the cached shell. We race the fetch
- // against a short grace timer: network wins → freshest HTML as usual; timer
- // wins (or 5xx/failure) → serve the cached shell instantly while the network
- // response still lands in the cache for next time. A slightly stale shell is
- // safe: deploys keep old immutable chunks for a 14-day grace window.
- if (request.mode === 'navigate') {
- event.respondWith(
- (async () => {
- const cache = await caches.open(CACHE);
- const cached = await cache.match('/');
- const network = fetch(request)
- .then((res) => {
- if (res && res.ok && (res.headers.get('content-type') || '').includes('text/html')) {
- cache.put('/', res.clone()).catch(() => {});
- }
- return res;
- })
- .catch(() => null);
- if (!cached) return (await network) || Response.error(); // first visit: network only
- const winner = await Promise.race([
- network,
- new Promise((resolve) => setTimeout(() => resolve('slow'), NAV_TIMEOUT_MS)),
- ]);
- return winner && winner !== 'slow' && winner.ok ? winner : cached;
- })()
- );
- return;
- }
-
- // Static assets: serve from cache if present, else fetch and cache for next time
- // (cache-as-you-go — no install storm). Only cache successful same-origin GETs.
- event.respondWith(
- caches.match(request).then((cached) => {
- if (cached) return cached;
- return fetch(request).then((res) => {
- if (res && res.ok && res.type === 'basic') {
- const copy = res.clone();
- caches.open(CACHE).then((c) => c.put(request, copy)).catch(() => {});
- }
- return res;
- });
- })
- );
-});
diff --git a/frontend/static/service-worker.js b/frontend/static/service-worker.js
new file mode 100644
index 0000000..1433c38
--- /dev/null
+++ b/frontend/static/service-worker.js
@@ -0,0 +1,31 @@
+// Kill switch — the app no longer uses a service worker.
+//
+// It was added for nice-to-have PWA/offline caching, but it sat in the boot
+// path and put first loads at risk (post-deploy chunk stalls), which a young
+// site with few visitors can't afford. Browser HTTP cache + the Cloudflare edge
+// are enough for a news site.
+//
+// Existing visitors still have the OLD worker registered and controlling their
+// pages. This replacement (served at /service-worker.js, no-cache) takes over,
+// wipes the old caches, and unregisters itself. There is deliberately NO fetch
+// handler, so every request goes straight to the network / browser HTTP cache.
+// The app also unregisters any worker on load (see app.html) as a backstop.
+self.addEventListener('install', () => self.skipWaiting());
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ (async () => {
+ try {
+ const keys = await caches.keys();
+ await Promise.all(keys.map((k) => caches.delete(k)));
+ } catch (e) {
+ /* best effort */
+ }
+ try {
+ await self.registration.unregister();
+ } catch (e) {
+ /* best effort */
+ }
+ })()
+ );
+});