diff --git a/src/components/__tests__/service-worker-registrar.test.ts b/src/components/__tests__/service-worker-registrar.test.ts
new file mode 100644
index 000000000..d4ece3ff7
--- /dev/null
+++ b/src/components/__tests__/service-worker-registrar.test.ts
@@ -0,0 +1,31 @@
+/**
+ * Gating predicate for app-wide service-worker registration.
+ *
+ * The registrar must install `/sw.js` only in production and only where the
+ * browser supports the API. Dev lacks the generated `/sw-version.js` (the
+ * `prebuild` step writes it) and a caching SW fights HMR, so a dev register
+ * would be actively harmful.
+ */
+import { describe, expect, it } from "vitest";
+
+import { shouldRegisterServiceWorker } from "../service-worker-registrar";
+
+describe("shouldRegisterServiceWorker", () => {
+ it("registers in production with window + serviceWorker support", () => {
+ expect(shouldRegisterServiceWorker("production", true, true)).toBe(true);
+ });
+
+ it("does not register outside production", () => {
+ expect(shouldRegisterServiceWorker("development", true, true)).toBe(false);
+ expect(shouldRegisterServiceWorker("test", true, true)).toBe(false);
+ expect(shouldRegisterServiceWorker(undefined, true, true)).toBe(false);
+ });
+
+ it("does not register without a window (SSR)", () => {
+ expect(shouldRegisterServiceWorker("production", false, true)).toBe(false);
+ });
+
+ it("does not register when serviceWorker is unsupported", () => {
+ expect(shouldRegisterServiceWorker("production", true, false)).toBe(false);
+ });
+});
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
index 69340dec8..dfba33019 100644
--- a/src/components/providers.tsx
+++ b/src/components/providers.tsx
@@ -19,6 +19,7 @@ import type { Locale } from "@/lib/i18n/config";
import { Toaster } from "@/components/ui/sonner";
import { AppSettingsProvider } from "@/components/app-settings-provider";
import { VersionPoller } from "@/components/version-poller";
+import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications";
@@ -184,6 +185,7 @@ export function Providers({
{children}
+
diff --git a/src/components/service-worker-registrar.tsx b/src/components/service-worker-registrar.tsx
new file mode 100644
index 000000000..fb33f5fea
--- /dev/null
+++ b/src/components/service-worker-registrar.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useEffect } from "react";
+
+/**
+ * Pure predicate for whether app-wide SW registration should proceed.
+ * Extracted so the production + capability gating is unit-testable without
+ * a DOM/effect harness. The registrar's `useEffect` calls this before it
+ * touches `navigator.serviceWorker`.
+ */
+export function shouldRegisterServiceWorker(
+ nodeEnv: string | undefined,
+ hasWindow: boolean,
+ hasServiceWorker: boolean,
+): boolean {
+ if (nodeEnv !== "production") return false;
+ if (!hasWindow) return false;
+ if (!hasServiceWorker) return false;
+ return true;
+}
+
+/**
+ * App-wide service-worker registration.
+ *
+ * Until this component shipped, `/sw.js` was registered exactly once —
+ * inside the Web-Push opt-in (`web-push-card.tsx`). Every user who never
+ * enabled notifications ran with no SW at all, so the offline shell cache,
+ * the precached app shell, and the network-first page cache were inert. This
+ * mounts the registration for everyone, gated on production.
+ *
+ * Lifecycle owner split: this component only INSTALLS the worker. The
+ * update→reload path stays solely with `` (polls
+ * `/api/version`, then unregister + cache-wipe + reload on a server-ahead
+ * deploy). No `updatefound` / `controllerchange` handling lives here — a
+ * second reload trigger would double-fire against the poller and risk a loop.
+ */
+export function ServiceWorkerRegistrar(): null {
+ useEffect(() => {
+ // Production only: dev lacks the generated `/sw-version.js` (written by
+ // the `prebuild` step), and a caching SW fights HMR. `process.env.
+ // NODE_ENV` is inlined into the client bundle at build time.
+ if (
+ !shouldRegisterServiceWorker(
+ process.env.NODE_ENV,
+ typeof window !== "undefined",
+ typeof navigator !== "undefined" && "serviceWorker" in navigator,
+ )
+ ) {
+ return;
+ }
+
+ const register = () => {
+ const idle =
+ (window as unknown as { requestIdleCallback?: typeof setTimeout })
+ .requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1));
+ idle(() => {
+ navigator.serviceWorker
+ // `updateViaCache: "none"` forces the browser to byte-check
+ // `/sw.js` (and its `importScripts('/sw-version.js')`) against the
+ // network on every update check rather than honouring the HTTP
+ // cache — without it a cached `sw.js` can mask a new deploy and the
+ // worker never sees the new `CACHE_VERSION`.
+ .register("/sw.js", { scope: "/", updateViaCache: "none" })
+ .catch(() => {
+ /* best effort — registration failure must never break the app */
+ });
+ });
+ };
+
+ // Defer to `load` (+ idle) so install/precache never contends with the
+ // first-paint critical path. If the window already fired `load`, run now.
+ if (document.readyState === "complete") {
+ register();
+ return;
+ }
+ window.addEventListener("load", register, { once: true });
+ return () => window.removeEventListener("load", register);
+ }, []);
+
+ return null;
+}