Focus Pages Stale — Root Cause Found & Permanently Fixed
Section titled “Focus Pages Stale — Root Cause Found & Permanently Fixed”Date: 2026-06-26
Repos: ~/projects/monorepo, ~/utils/web/web-deploy
Follows (failed prior attempts): 2026-06-20_Focus-Pages-Cache-Fix, 2026-06-23_Focus-Pages-Bfcache-Fix
The recurring problem
Section titled “The recurring problem”MBR + SDC focus page PWAs (Android, iPod) kept showing stale content — task lists with outdated priorities. “Fixed” 2–3 times already; each fix held briefly then regressed.
Why every prior fix failed — the real root cause
Section titled “Why every prior fix failed — the real root cause”The installed PWA loads from talbotstevens.com, which runs Microsoft-IIS/10.0 on
Plesk/Windows (server: Microsoft-IIS/10.0, x-powered-by-plesk: PleskWin).
Both prior fixes targeted the wrong server type:
| Prior fix (date) | Mechanism added | Why it did nothing on this host |
|---|---|---|
| 2026-06-20 | public/_headers | Cloudflare-Pages-only — never applies to the FTP/IIS copy the PWA installs from |
| 2026-06-20 | public/.htaccess | Apache-only. IIS ignores .htaccess entirely (even 404s .ht* via hiddenSegments) |
| 2026-06-23 | bfcache reload handlers (JS) | Correct & kept — but location.reload() honors the HTTP cache; with no cache header it re-serves stale bytes |
Net effect: the server sent no Cache-Control header at all. Browsers fell back to
heuristic caching ((now − Last-Modified) × 0.1, potentially hours), so each phone cached
focus.html and never revalidated. Deploys updated the server file, but the phone never saw it.
Diagnosis evidence (live, pre-fix)
Section titled “Diagnosis evidence (live, pre-fix)”$ curl -sI https://talbotstevens.com/mbr/focus.htmlHTTP/2 200content-type: text/htmllast-modified: Thu, 25 Jun 2026 18:12:33 GMTetag: "8e0032ce4dd1:0"server: Microsoft-IIS/10.0 ← IIS, not Apachex-powered-by-plesk: PleskWin ← NO cache-control line at all$ curl -sI https://talbotstevens.com/mbr/.htaccess → HTTP/2 404 (IIS never processes it)The deployed HTML itself was current (had the latest KB content + the bfcache handlers) — so the build/content pipeline was fine. The defect was 100% the missing server cache directive.
The permanent fix
Section titled “The permanent fix”Replace the dead Apache .htaccess with an IIS web.config that emits Cache-Control: no-cache
on focus.html.
sites/mbr/public/web.config + sites/sdc/public/web.config (new):
<configuration> <location path="focus.html"> <system.webServer> <staticContent> <clientCache cacheControlMode="DisableCache" /> </staticContent> </system.webServer> </location></configuration>DisableCache → Cache-Control: no-cache (+ Expires: -1). Astro copies public/ to dist/, so
it deploys automatically.
Changes:
sites/mbr/public/web.config,sites/sdc/public/web.config— createdsites/mbr/public/.htaccess,sites/sdc/public/.htaccess— deleted (dead on IIS)web-deploy/focus-deploy/deploy_mbr_focus.py+deploy_sdc_focus.py—FAVICON_FILES:.htaccess→web.configmonorepo/LESSONS.md+docs/focus-pages.md— corrected the (wrong) Apache root cause
Verification (live, post-fix)
Section titled “Verification (live, post-fix)”$ curl -sI https://talbotstevens.com/mbr/focus.html | grep -i cache-controlcache-control: no-cache ✓ (same for /sdc/)
$ curl -sI -H "If-Modified-Since: <last-modified>" .../mbr/focus.htmlHTTP/2 304 ✓ cheap revalidation when unchanged
$ curl -sI https://talbotstevens.com/mbr/web.config → HTTP/2 404 ✓ IIS protects itFull state machine now works: PWA resume → bfcache pageshow/visibilitychange reload →
conditional GET (no-cache) → 304 instant if unchanged, 200 fresh if redeployed. No
heuristic caching, no stale task lists.
The rule going forward (added to LESSONS.md)
Section titled “The rule going forward (added to LESSONS.md)”talbotstevens.com is IIS — use web.config, never .htaccess. After any focus-page cache
change, the fix is only real if the header is live:
curl -sI https://talbotstevens.com/<brand>/focus.html | grep -i cache-control → must show no-cache.
Files uploaded ≠ header applied.
Deploy note (environment)
Section titled “Deploy note (environment)”FTP from this WSL/sandbox env had intermittent DNS (gaierror -2) on ftp.talbotstevens.com and
the deploy script’s nested subprocess got re-sandboxed. Worked around for this deploy by uploading
via a single direct ftplib session to the resolved IP (23.111.70.74), creds from
web-deploy/.env via python-dotenv (bash source injected a CR → ftplib “illegal newline”).
Not a code defect — only relevant if deploying focus pages from inside the agent sandbox again.
Follow-up (same day): Android still stale → client-side build-id check
Section titled “Follow-up (same day): Android still stale → client-side build-id check”Talbot’s feedback after the header fix: iPad/iOS self-refreshed perfectly (no touch needed), but Android Chrome PWA was still stale even after close+reopen. Also requested: confirm the fix works after the planned IIS → Apache/Linux migration.
Why Android stayed stale: the no-cache header only governs future responses. Android had a
pre-fix heuristic cache entry it still considered fresh, so it never revalidated focus.html, and
location.reload() honors that cached entry rather than forcing the network. A header change can’t
evict an already-poisoned cache entry. HTTP cache behavior can’t be relied on for correctness on
Android PWAs.
Robust, host-independent fix — client build-id check (in focus.astro):
- Each build stamps
<meta name="focus-build" content="<ISO timestamp>">(unique per build). - Inline script, on load + bfcache
pageshow+ resumevisibilitychange, runs:fetch(location.pathname + '?_=' + Date.now(), { cache: 'no-store' })→ parse the live build id → if it differs,location.replace(location.pathname + '?v=' + liveId)(guaranteed cache miss → fresh). no-store+ cache-bust query means the freshness check can never be served from a poisoned cache, on any host. Degrades gracefully offline (fetch fails → keep current page). Throttled to avoid redundant checks; no redirect loop once fresh (verified).
Dual-host (answers “works on IIS and Apache?”): YES. Deploy now ships both
web.config (IIS) and .htaccess (Apache) — each host honors its own, ignores the other, so the
migration needs no change. .htaccess also denies public access to web.config on Apache. And the
client build-id check is header-independent, so it works identically before, during, and after the
migration.
Changes (follow-up):
sites/{mbr,sdc}/src/pages/focus.astro—buildIdconst +<meta name="focus-build">+ replaced the plain-reload freshness handlers with the build-id network check.sites/{mbr,sdc}/public/.htaccess— re-created (Apache header + deny web.config).web-deploy/focus-deploy/deploy_{mbr,sdc}_focus.py—FAVICON_FILESships bothweb.config+.htaccess.- Rebuilt + deployed both.
Verification (real Chromium via Playwright, against live site):
- Fresh page → no redirect loop, current content renders. ✓
- Stale client (forced old build id) → same-origin
no-storefetch detects newer build → wouldlocation.replace('?v=<live id>'). ✓ - Live:
cache-control: no-cacheon both;<meta name="focus-build">present;?_=and?v=URLs → 200. ✓
This closes the Android gap and is migration-proof.
Follow-up 2 (same day): open-page auto-update (Tier 2 poll)
Section titled “Follow-up 2 (same day): open-page auto-update (Tier 2 poll)”Talbot’s question: can a PWA auto-refresh shortly after content is pushed, like the iPad appeared to do? Answer given: the iPad wasn’t doing push — it was open/resumed and revalidated. A PWA runs code only while open (no service worker here), so:
- Open / resumed page: yes — and now deterministic (below).
- Fully-closed app, proactively: only via Service Worker + Web Push (Android real; iOS 16.4+ installed-PWA notifications only) — deliberately not built (too heavy for a personal dashboard).
Upgrade shipped — visibility-gated 60s poll. The existing checkFresh() now also runs on
setInterval(…, 60000), started on load/resume and cleared while the page is hidden (zero
battery/network in background). An open focus page self-refreshes shortly after a deploy with no
user action. No service worker; cross-platform (Android, iOS, desktop).
Changes: sites/{mbr,sdc}/src/pages/focus.astro — added startPolling/stopPolling wired into
the load + visibilitychange handlers. Rebuilt + deployed both focus.html. Docs updated:
README.md (Focus Pages → freshness subsection), docs/focus-pages.md, LESSONS.md.
Verification (real Chromium):
- Poll logic (stubbed, fast interval): no false nav when unchanged; poll detects a new build while open and navigates; repeat checks issued; polling pauses when hidden (fetch count flat). 4/4.
- Live unattended E2E: opened
talbotstevens.com/mbr/focus.html, left it untouched, pushed a new build mid-session → the open page auto-navigated to?v=<new id>and rendered the new build within the poll window. 4/4.