Skip to content

WIP: Client offline mode through Service Workers#425

Draft
tuj wants to merge 2 commits into
release/3.0.0from
feature/sw-offline-mode
Draft

WIP: Client offline mode through Service Workers#425
tuj wants to merge 2 commits into
release/3.0.0from
feature/sw-offline-mode

Conversation

@tuj
Copy link
Copy Markdown
Contributor

@tuj tuj commented May 4, 2026

Link to ticket

https://leantime.itkdev.dk/#/tickets/showTicket/7398

Description

As supplement to online-check use service worker to cache assets and serve when offline on boot.

Things to consider:

  • Is the added complexity of service workers an upgrade over online-check that is more simple?

see: https://developer.chrome.com/docs/workbox/service-worker-overview

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

Service Worker for Offline Client App

Context

The client app runs on dedicated screen devices. In production, if a device loses internet after initial setup, it should still be able to load the client app from cache after a reboot with no internet.

The goal is: first load caches the app shell (HTML, JS, CSS, static assets), subsequent loads serve from cache, and the cache updates when new versions are deployed.

Approach: Hand-written Service Worker with build-time asset injection

Using a custom SW rather than vite-plugin-pwa/Workbox because:

  • The HTML is served by Symfony (Twig template), not a static file — vite-plugin-pwa is designed for static index.html
  • The caching needs are simple (one page + hashed assets)
  • Full control over update behavior on signage devices

Cache-Control: no-cache ensures the browser always checks for SW updates (browsers already cap at 24h, but explicit is better).

How updates work

  1. New deploy → new release.json timestamp, new hashed assets, new sw.js (different __BUILD_HASH__)
  2. Browser detects sw.js changed → installs new SW in background (precaches new assets)
  3. Existing ReleaseService (assets/client/service/release-service.js) detects timestamp change → calls window.location.replace()
  4. Page reload activates new SW (since skipWaiting() was called) → serves new HTML + new assets
  5. Old cache is cleaned up on activate

No changes needed to ReleaseService — it already handles the "when to update" problem.

Verification

  1. Run npm run build — confirm public/sw.js has the manifest injected (no more placeholders)
  2. Verify the precache list contains only client assets (not admin/template)
  3. Serve the built app locally, open /client in Chrome DevTools → Application → Service Workers — confirm SW is registered
  4. In DevTools → Application → Cache Storage — confirm assets are cached
  5. Toggle "Offline" in DevTools Network tab → reload /client — confirm it loads from cache

@tuj tuj self-assigned this May 4, 2026
@tuj tuj added the backlog Future fixes and improvements label May 4, 2026
@tuj tuj marked this pull request as draft May 4, 2026 07:15
@tuj tuj changed the title Client offline mode Client offline mode through Service Workers May 4, 2026
@tuj tuj changed the title Client offline mode through Service Workers WIP: Client offline mode through Service Workers May 4, 2026
@turegjorup
Copy link
Copy Markdown
Contributor

Local review notes — flagging this as not blocking. The scope here is genuinely bigger than what /client/online-check does today: that page only delays SPA load until the network is reachable, whereas this PR aims to actually boot the SPA from cache when the network is gone. Being able to come up to a state where we can render previously-fetched content is a real win over the current offline check, and the trade-offs below are about tightening the implementation, not about whether to do this at all.

1. Comparison with /client/online-check/index.html

The two solve different problems and the PR does not retire or supersede the existing page — both still exist after this PR.

online-check page SW in this PR
Goal Don't boot SPA until network is verified Boot SPA from cache when network is gone
Code surface One self-contained HTML, all inline (public/client/online-check/index.html) 60-line generator + 91-line template + nginx rules + Vite/build wiring + a registration hook
Failure modes Mostly one (cache served, no network → keeps polling, shown as "Offline") Several: install atomicity, cache eviction, sw.js update timing, asset-hash drift, navigation-cache miss
Build coupling None Tightly coupled to the Vite manifest crawl
First boot Page works whether or not network is present Does not help first-ever boot (see §3)

Stability: the existing page has a much smaller blast radius. Everything is inline; no install step that can half-finish, no version skew, no race with browser SW lifecycle. The PR introduces several non-trivial offline-boot failure modes that aren't possible today — worth it for the broader goal, but worth being explicit about.

If the intent is to replace online-check with the SW, this PR doesn't do that — README.md:628-632 still describes online-check as the recommended startup URL and nothing here adapts that flow. Either the two should co-exist deliberately (online-check remains the startup URL, SW makes the SPA itself resilient) or this PR should retire the page and migrate the docs.

2. How browsers cache the SW file (and how the PR holds up)

Browsers treat /sw.js specially in three ways relevant here:

  1. Update checks bypass the HTTP cache for the main script. With the default updateViaCache: "imports" (the PR uses defaults at assets/client/index.jsx:15), the browser always fetches sw.js fresh during update checks. Sub-imports (none here) would honor the HTTP cache.
  2. Max-Age is capped at 86400 s. Even if a CDN sets a year of caching, browsers cap SW script caching at 24 hours. The nginx Cache-Control: no-cache is therefore belt-and-suspenders — correct, but largely redundant.
  3. Updates fire on navigations in scope. A new sw.js is detected only when (a) a controlled page navigates, or (b) registration.update() is called. The PR does neither beyond initial registration. Combined with skipWaiting() + clients.claim() at scripts/sw-template.js:19,37, when a navigation finally happens the new SW takes over instantly mid-flight — but only on that navigation. Client screens may run for days without navigating.

Verdict against "ensure offline boot":

  • The byte-comparison + content-hashed BUILD_HASH (scripts/sw-template.js:5) means each build is detected as new, so updates aren't masked by HTTP caching. ✅
  • Service-Worker-Allowed: / with the script at root and scope / is correct.
  • The 24-hour cap is irrelevant to the offline-boot goal; it just affects when long-running screens pick up new builds.

3. Cache stability — what the PR can actually guarantee is in cache

Only what is reachable from the Vite manifest entry assets/client/index.jsx (scripts/generate-sw.js:14,26-39). Concretely from the current manifest:

  • assets/client-….js (entry)
  • assets/client-….css
  • _templates-….js, _setPrototypeOf-…, _esm-…, _chunk-… (transitive imports)
  • assets/fallback-….png, assets/logo-….svg

What this does not include — and these are necessary for "offline boot":

Asset Status Impact
/client HTML (templates/client/client.html.twig) Not in PRECACHE; only added by networkFirst after the SW is active and a navigation occurs First offline boot fails entirely (see §4)
/release.json Not precached First offline boot can't read release; ReleaseLoader logs ERROR_RELEASE_FILE_NOT_LOADED but app continues
/v2/authentication/screen, /v2/authentication/token/refresh, /v2/tenants/... Not handled by SW App shell loads, then login fails offline
Slide content (/media/..., feeds) Not handled by SW No cached content to render
Custom templates dir Empty in this checkout (eager glob — bundled if present); worth confirming for tenants that ship custom templates

Slide templates from assets/shared/templates/*.jsx are loaded with import.meta.glob(..., { eager: true }) (assets/shared/slide-utils/templates.js:5-8), so they're inlined into the entry chunks and are in the manifest crawl. That's fine.

cache.addAll(PRECACHE_ASSETS) at scripts/sw-template.js:18 is atomic: any single asset failing aborts install, and skipWaiting() is chained after it, so a failed install also means no activate, no claim. Right semantic, but a transient 5xx on one asset during install means no offline support until the next install attempt.

4. What the PR does when assets are missing from cache

Per fetch handler (scripts/sw-template.js:67-91):

  1. /client navigation, cache miss, online: networkFirst fetches and caches under /client. ✅
  2. /client navigation, cache miss, offline: networkFirst throws on fetch, falls back to cache, cache is empty under /client → re-throws → browser shows its native "no internet" page. App fails to boot.
  3. /build/assets/... cache miss, online: cacheFirst fetches and caches. ✅
  4. /build/assets/... cache miss, offline: cacheFirst falls through to fetch, throws → the chunk fails to load → React app errors out.
  5. /release.json cache miss, offline: networkFirst throws → ReleaseLoader sets releaseTimestamp: null, app sets ERROR_RELEASE_FILE_NOT_LOADED but continues. Survives.
  6. API/media: handler returns nothing, default network fetch, fails offline → behavior depends on each consumer's own error handling.

The big one: first-boot offline gap

/client HTML is not part of PRECACHE_ASSETS and registration runs from index.jsx (i.e., after /client was already fetched without SW interception):

boot 1 (online):
  GET /client     ← uncached, no SW yet
  GET /build/...  ← uncached, no SW yet
  SW registers, installs, addAll(PRECACHE) ✅, activate, clients.claim
  → cache contains /build/... but NOT /client

boot 2 (online): GET /client passes through SW networkFirst → cache /client ✅
boot 2 (offline before reload happened): cache miss for /client → broken

So the offline-boot guarantee requires the screen to be online for at least two successful navigations to /client, not one. This is exactly the situation online-check was designed to avoid (cached SPA loaded without network). Easy fix — add /client to the install-time cache:

// in install handler, decoupled from atomic addAll
await cache.addAll(PRECACHE_ASSETS);
const html = await fetch("/client", { credentials: "same-origin" });
if (html.ok) await cache.put("/client", html);

Other things worth raising before merging

  • activate deletes prior caches immediately (sw-template.js:23-39) while clients.claim() swaps controllers mid-flight. Pages already loaded against the previous build's chunk URLs will, if they trigger any further dynamic load, hit the new SW with old URLs that are no longer in cache (and, because vite build emptyOutDir, no longer on disk either) → 404. A kiosk that never closes the tab is more exposed than a desktop browser. Consider keeping the previous cache around until current clients are gone, or skipping clients.claim() on upgrade.
  • The PR does not surface install/activation failures anywhere. A screen with a permanently-failing SW install is silent. Consider posting a message to clients on install failure or wiring a status field.
  • README still tells operators to set the startup URL to /client/online-check. Either delete that page in this PR (and update README + CHANGELOG) or document how the two coexist.

TL;DR

  • vs online-check: different feature with bigger surface area, with a real upside (cached content boot). Worth doing — but the PR doesn't reconcile docs/startup-URL with the existing page.
  • Cache stability: only /build/... assets reachable from the Vite manifest entry are guaranteed. /client HTML, release.json, and all /v2/... calls are not — and atomic addAll is fragile to one-off network errors.
  • Offline boot: as written, the PR can't fully satisfy the stated goal on first boot. /client HTML is only cached after a SW-controlled navigation, so a screen that boots online once and then loses network has cached /build/... chunks but no HTML to load them from. Browser-special handling of sw.js is fine here; the gap is in what the SW itself caches at install time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backlog Future fixes and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants