Slow load after a deploy (#911) - the FIX run โ
The investigation run is README.md in this folder and stays as it was written. This document is the fix: what the mechanism turned out to be, the measurements that established it, and the scenarios that have to pass before the fix is called done.
| Field | Value |
|---|---|
| Issue | bug(web): the app loads very slowly after a deploy unless the browser cache is cleared manually (#911) |
| Branch / PR | fix/911-slow-load-fix |
| Environment | two real apps/web production builds served from a loopback host that reproduces apps/web/public/_headers; plus deployed dev, https://dev.ourlantern.app |
| Instrument | tooling/e2e/sw-deploy-swap.e2e.mjs, headless chromium, Pixel 7 profile, 400kbps / 400ms shaped in the SERVER |
| Tester | agent for 1 to 8, operator for 9 |
| Date | 2026-08-20 |
Summary โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 1 | Every deploy replaces the entire precache manifest | [x] confirmed | 0 of 40 filenames shared between two builds of identical source |
| 2 | Returning user, deploy landed between visits | [x] measured | no slow load, and no update either: still on the old build after four opens |
| 3 | Deploy lands while the app is open: the purge path | [x] confirmed, this is the bug | 41,667ms of blank screen, 1,924KB, precache 43 to 0 |
| 4 | Recovery after the purge | [x] measured | slow once, then about 1.2s, still above control |
| 5 | After the fix: no-deploy control is unchanged | [x] pass | 729ms against a 716ms pre-fix control |
| 6 | After the fix: a returning user actually gets the new build | [x] fail, and it failed before the fix too | still on the old build after four opens; not a regression, and not closed |
| 7 | After the fix: the update no longer takes the app down | [x] pass | blank window 705ms and 0KB, against 41,667ms and 1,924KB |
| 8 | Activation: the fix is live in a shipped build with no flag | [x] pass | no flag exists; measured on a plain npm run build:app output |
| 9a | Real deploy, BEFORE arm (dev running the old code) | [x] pass | mt0ug5gl to mt21dn6d: 16 of 16 URLs moved, 19 of 19 loaded assets stale |
| 9b | Real deploy, AFTER arm (dev running the fix) | [x] pass | 237ms blank window, 0KB, on mt22b2cn. Its written expectation was WRONG and is corrected in place |
| 10 | Operator confirms the symptom is gone on her device | [ ] pass [ ] fail [ ] blocked | post-merge |
Why this run exists at all โ
The investigation established that a precache miss COSTS about 12 seconds. It never established that a real deploy PRODUCES one, and the fix was held on exactly that gap. Waiting for a deploy was not the only way to close it: two consecutive npm run build:app runs, served from the same origin with the served root swapped between them, is a real deploy in every respect except the host. Real hashes, real workbox manifest, real sw.js, real version.json. The app polls /version.json off the origin, so swapping the root also drives the update path with no interception, which retires every spoofing trap the earlier harness had to work around.
Setup / preconditions โ
Two build roots. Build them from IDENTICAL source, which is the sharper test: it isolates what a DEPLOY costs from what a code change costs.
OUT=/tmp/lantern-builds for t in A B; do rm -rf apps/web/dist && npm run build:app && cp -r apps/web/dist "$OUT/$t" done.env.localmust be present at the repo root or the build produces a bundle that throwsMissing required Firebase environment variablesand never renders, which silently turns every timing into the harness timeout.Run:
node tooling/e2e/sw-deploy-swap.e2e.mjs --builds=$OUTfor the returning-user case, and--update-pathfor the tab-open case.Bandwidth shaping is server-side on purpose. See scenario 3's harness note.
Scenarios โ
1. Every deploy replaces the entire precache manifest โ
- Goal: Establish whether a deploy actually moves asset URLs, which is the precondition the whole precache-miss theory rests on and which the earlier run recorded as unproven.
- Steps:
- Build
apps/webtwice with no source change between the builds. - Compare the two
dist/assets/filename sets. - Confirm the same pattern on the deployed build: read
/version.jsonand the asset URLs in the servedindex.html.
- Build
- Expected if the theory holds: the two sets are DISJOINT, so nothing a returning user already has can still match.
- Verify:
apps/web/vite.config.mjssetsentryFileNamestoassets/[name]-${buildId}.[hash].js, andtooling/scripts/generate-version.mjscomputesbuildIdasDate.now().toString(36)on every build.
Result: [x] confirmed
Actual: 0 of 40 filenames shared. Two builds of byte-identical source produced
two completely disjoint asset sets. The content hash moves too, not
just the buildId segment, because the buildId appears inside each
chunk's import specifiers and the hash cascades.
Evidence: build A mt1p7frz, build B mt1p7j87. Deployed dev at the same moment
served buildId mt0ug5gl and every asset was named
/assets/<name>-mt0ug5gl.<hash>.js, so this is how the deployed
artifact is built, not a local quirk.
Follow-up: this makes 100% precache replacement unconditional. It happens on a
docs-only deploy. The precache cannot be warm across a deploy.2. Returning user, the deploy landed between visits โ
- Goal: The reported symptom is a returning user. Measure what one pays when the deploy happened while the app was closed.
- Preconditions: profile primed on build A with a settled precache.
- Steps:
- Prime a persistent profile against build A, held open until the precache stops growing.
- Take a control open with no deploy.
- Swap the served root to build B.
- Open four more times, 15s each, which is a glance rather than a held tab.
- Expected if a precache miss is the mechanism: the first open after the swap goes to the network and costs roughly the cold figure.
- Verify: which build generation the DOM references, and the transferred bytes per open.
Result: [x] measured, and it refutes the simple version of the theory
Actual: control 739ms; the four opens after the deploy were 667, 650, 618 and
725ms, every asset served by the worker, 0 bytes over the wire.
The DOM referenced build A on ALL FOUR, so the user was never slow
and never updated either. The precache crept 43 -> 53 -> 57 -> 64 -> 66
across the visits: the new worker's install had not finished, so it
never activated, so the old build kept being served.
Evidence: /tmp/lantern-deploy-swap.json
Follow-up: a returning user on a weak link can sit on a stale build for a long
time. That is a correctness problem in its own right and it is why
scenario 6 exists: the fix must not buy speed by never updating.3. The deploy lands while the app is open: the purge path โ
- Goal:
versionCheckdeletes every Cache API cache and reloads when the build id moves. Measure that on a REAL hash change rather than a spoofed one. - Preconditions: profile primed on build A, precache settled.
- Steps:
- Open the primed profile on build A and wait for the console line
Version check initialized, so the app has captured its own baseline before anything changes. - Swap the served root to build B. This is the deploy.
- Call
lantern.dev.versionCheck.force(), becausevisibilitychangenever fires headlessly and the fallback interval is an hour. - Count precache entries before and after, watch for the reload, and measure how long until the app is usable again.
- Open the primed profile on build A and wait for the console line
- Expected: a reload, and a cost.
- Verify: precache entry count either side of the purge, bytes transferred, and which generation the recovered page references.
Result: [x] confirmed. This is the mechanism.
Actual: precache 43 -> 0. The app reloads into an empty cache and is a BLANK
RELOADING PAGE for 41,667ms while it pulls 1,924KB back over the
wire. All 20 assets were worker-MISS, none were cache hits. Control
on the same link: 716ms and 0KB.
The three opens after it: 1,227ms, 1,349ms and 1,180ms, still roughly
double the control while the precache refills to 31 of 43.
Evidence: /tmp/lantern-bw-before.json
Follow-up: the purge is the expensive half, not the reload. Deleting the Cache
API caches destroys the precache the OLD worker is still serving
from, and nothing re-populates it, because a workbox precache is
populated during `install` and an already-installed worker does not
install again. The NEW worker's part-built precache is deleted in the
same sweep, so its install restarts from nothing on a weak link.
The reload is separately useless: after it, the DOM still referenced
build A, because the still-active old worker served the old shell.Harness note, because the first version of this run got it wrong. CDP's Network.emulateNetworkConditions is attached to the PAGE's session, and the service worker fetches from its own context. A CDP-throttled run leaves the background precache downloading at loopback speed, and it reported that a deploy costs a returning user nothing. That was an artifact of an unthrottled worker. Shaping moved into the server, as one shared token bucket across every in-flight response, so the worker is on the same link as the page and six sockets do not buy six times the bandwidth.
Attribution note. A worker that MISSES its precache still fetches, and the resulting PerformanceResourceTiming still has workerStart > 0. Worker-handled is not worker-served. The split is on transferSize, not on who answered. The run before this one bucketed on workerStart alone and therefore reported every post-purge fetch as cache-served.
Waiter note. The reload destroys the execution context, which rejects a pending waitForFunction. Catching that as a timeout made the first run report that the app never came back when it had merely navigated. The waiter now retries across navigations, and only a real deadline is a timeout.
Why this took two days: a wrong reading was believed and acted on โ
Stating this plainly, because "the instrument was fixed" and "the instrument had already produced a false conclusion that people acted on" are different claims, and only the second one explains the delay.
On 2026-08-18 the msxsov4c -> msyto7tz deploy was measured and reported 0 of 40 asset URLs changed, 40 of 40 served by the worker. That was read as evidence that a deploy does not necessarily move asset hashes, and the fix was held pending a deploy that would.
That deploy had already moved every asset URL. The reading was the instrument: --measure read the URLs off the LOADED document, and the worker serves the previous build's index.html on the first open after a deploy, so it compared the old asset set against itself.
The wrong reading did not just sit in a log. It propagated to four places and hardened:
| Where it went | What it became |
|---|---|
| the harness header comment | stated as fact, and used to justify the guard below it |
| the frozen investigation doc, scenario 4 | recorded as a precondition that had not been met |
| the memory corpus | graduated into a durable lesson for future sessions |
| the harness itself | a guard built to catch a condition that had been misdiagnosed |
The sharpest part is the lesson it produced. The investigation concluded "a buildId change is NOT evidence of an asset change". That is true in general, and false here: in this repo the build id is baked into every asset filename, so a buildId change is perfect evidence of an asset change. A correct-sounding general principle was recruited to explain away a specific reading that was itself wrong, and being correct in general is exactly what stopped anyone checking whether its premise held in this repo.
All four sites are retracted in place rather than quietly overwritten.
Who actually pays this, and it is not who the issue assumed โ
The issue is titled around a returning user, and every scenario here was built that way. The real-deploy run (9a) says that framing is wrong.
versionCheck takes its baseline from the first version.json fetch of the session. So a session that STARTS after a deploy reads the new build id on both sides, finds no mismatch, and never purges anything. It gets the fast, stale path: 703ms, everything worker-served, running the previous build.
The purge needs a session whose baseline predates the deploy. That is the user whose app was already running when the deploy landed, and whose visibilitychange fires on return.
| Who | What they get | Measured |
|---|---|---|
| opens a closed app after the deploy | fast, and silently on the old build | yes, 9a: 703ms, 0 bytes, 19 of 19 stale |
| had the app open or backgrounded across the deploy | the purge, then a blank reloading page | yes, on loopback: 41,667ms |
On a phone the second row is the ordinary case, not the edge case, because backgrounding is how people leave an app rather than force-closing it, and returning to the foreground is exactly what fires visibilitychange.
That last step is inference from the code path, not something measured here. What is measured is that the baseline comes from the session's first fetch, and that a post-deploy session therefore cannot detect the change. Whether her phone keeps the PWA alive across a deploy is scenario 10's job, and it is the reason scenario 10's steps changed.
It also fits the original report better than the returning-user framing ever did. "Slow every time until I clear the cache manually" is hard to explain if each open is a fresh session, and easy to explain if the app is being resumed.
The mechanism, in one paragraph โ
Every deploy replaces the entire precache, because the build id is baked into every asset filename. When a deploy lands while the app is open, versionCheck deletes every Cache API cache, which is the precache the currently active worker is still serving from, and reloads. Nothing repopulates it: workbox fills a precache during install, and the worker that just lost its cache is already installed. The new worker's partially built precache is deleted in the same sweep. So the next time she opens the app it has no warm cache at all and pulls roughly a megabyte over the network, which is the twenty-plus seconds in the report.
4. Recovery after the purge โ
- Goal: Scenario 5 of the investigation asks the operator whether it is slow once or slow every time. Answer the measurable half.
- Steps: after scenario 3's purge, open three more times at 15s each.
- Expected: if the app repopulates its caches, later opens return to control.
Result: [x] measured: one long blank window, then above-control for a while
Actual: after the 41.7s blank window the next three opens cost 1,227ms,
1,349ms and 1,180ms with 0KB over the wire, against a 716ms control,
with the precache refilled to 31 of 43 and stalling there inside the
observed window.
Evidence: /tmp/lantern-bw-before.json
Follow-up: "slow every time until I clear it" is NOT reproduced here. Either her
phone recovers more slowly than this profile does, or there is a
second mechanism. Scenarios 9 and 10 decide it, and nothing in this
run licenses claiming her exact wording is explained.5. After the fix: the no-deploy control is unchanged โ
- Goal: The fix must not cost anything on the ordinary path, which is the overwhelming majority of opens.
- Steps: run the returning-user mode with no root swap.
- Expected: interactive time within noise of the pre-fix control (roughly 650 to 750ms on this link), all assets worker-served, 0 bytes over the wire.
Result: [x] pass
Actual: 729ms and 755ms across the two fixed runs, all 20 assets worker-hits,
0KB over the wire. The pre-fix control on the same link was 716ms, so
the ordinary path is unchanged. The spread across runs (716 to 878ms)
is larger than any difference between the two versions, which is the
honest reading: this change does not touch the ordinary path.
Evidence: /tmp/lantern-bw-after.json, /tmp/lantern-bw-reopen.json
Follow-up: none6. After the fix: a returning user actually gets the new build โ
- Goal: Scenario 2 showed a user can sit on a stale build indefinitely. A fix that only removes the purge would make that worse, not better.
- Steps: prime on A, swap to B, then open repeatedly at a realistic dwell.
- Expected: within a small number of opens the DOM references build B, and no open pays a full cold load to get there.
- Verify: DOM generation per open, and bytes transferred per open.
Result: [x] FAIL, and it is not a regression: it failed identically before
the fix. Reported rather than smoothed over.
Actual: four opens at 897, 869, 1013 and 859ms against a 755ms control, 0KB
over the wire, and the DOM referenced build A on ALL FOUR. The
precache crept 43 -> 53 -> 57 -> 63 -> 65 of the 83 it needs, so the
new worker's install IS making progress and would land after roughly
ten such visits.
Evidence: /tmp/lantern-bw-reopen.json, against the pre-fix
/tmp/lantern-deploy-swap.json which showed the same four stale opens
Follow-up: the cause is scenario 1. The new worker cannot activate until it has
precached the ENTIRE 4MB manifest, and the manifest is fully replaced
on every deploy because the build id is baked into every filename. A
one-line change costs the same 4MB as a rewrite. Fixing that is a
change to `apps/web/vite.config.mjs` output filenames, which is
outside this branch's scope, and it is filed separately.7. After the fix: the update no longer takes the app down โ
- Goal: The regression this whole run is about.
- Steps: repeat scenario 3 exactly, on the fixed build.
- Expected: the app is usable again in a time comparable to the control, and the precache is never emptied while a worker is serving from it.
- Verify: precache entry count either side of the update, and time to usable.
Result: [x] pass
Actual: BLANK WINDOW 705ms, against 41,667ms before the fix. 0KB over the
wire during the update, against 1,924KB. The precache is never
emptied: 43 -> 83 (both builds held at once) -> 43 once the new
worker cleans up the old entries.
The next three opens: 841ms, 780ms, 720ms, against 1,227 / 1,349 /
1,180ms before. Control unchanged at 729ms vs 716ms.
Evidence: /tmp/lantern-bw-after.json, against /tmp/lantern-bw-before.json
Follow-up: **the reload TIMESTAMP moved later, from 42,113ms to 103,280ms, and
that is the fix working rather than a regression.** It now waits for
the new precache to be ready before swapping, and the app is fully
usable on the old build for the whole wait. Only the blank window
describes the user, which is why it is instrumented directly (the new
document's own navigation timing) rather than argued from the reload
timestamp. The 103s is the 4MB background download, and it shrinks
with #937.7a. Before and after, same harness, same link โ
| before the fix | after the fix | |
|---|---|---|
| control, no deploy | 716ms, 0KB | 729ms, 0KB |
| blank window when the update lands | 41,667ms | 705ms |
| bytes over the wire during the update | 1,924KB | 0KB |
| the three opens after that | 1,227 / 1,349 / 1,180ms | 841 / 780 / 720ms |
| precache across the update | 43 to 0, never refilled by the old worker | 43 to 83 to 43 |
| when the reload happens | immediately, into an empty cache | at 103s, once the new precache is ready |
| does the user end up on the new build | yes, after 42s of blank screen | yes, after 705ms |
7b. Correction: the first version of these numbers was wrong โ
An earlier revision of this document reported the cost as 21,927ms on the open AFTER the update, with 1,012KB. That was an artifact of this harness, and the code review that found it is why these numbers are re-measured.
The reload wait was capped at 30 seconds. The recovery actually takes about 42, so the harness closed the browser 30 seconds into it and the unfinished work spilled into the next visit, which is where the 21.9s was recorded. A real user is not killed at 30 seconds. Four further instrument defects were fixed in the same pass (the bandwidth shaper under-delivering by an amount that varied with concurrency, a missing asset being served as a year-immutable HTML body, reload detection that the app's own hash routing could trip, and a run that could not tell two identical builds apart), so every number above was taken again from scratch.
The bug is worse than first reported, not better. 41.7 seconds of blank screen, not 21.9 seconds of slow load, and the whole asset set rather than half of it.
8. Activation: the fix is live in a shipped build with no flag โ
- Goal: A fix verified by driving it by hand cannot tell you the shipped default is on. Confirm the behaviour with no manual setup at all.
- Steps:
- Build
apps/webfresh with no flags set and no manual steps. - On a clean profile, confirm the changed behaviour is already the behaviour.
- Confirm the same on the deployed bundle after merge, by reading the served asset rather than the source.
- Build
- Expected: no env var, no toggle, no manual selection is required.
Result: [x] pass
Actual: there is no flag to be on the wrong side of. The change is an
unconditional edit to `checkForUpdates`, and every measurement in
scenarios 5 to 7 was taken against the output of a plain
`npm run build:app` with no flags and no manual steps, on a profile
created fresh by the harness.
Evidence: the fixed builds in the run above were produced by the same
`build-two` loop as the pre-fix ones, with the source change as the
only difference.
Follow-up: scenario 9 still has to confirm it on the DEPLOYED bundle after
merge, because a local build is not the shipped artifact.9a. Real deploy, BEFORE arm: the bug confirmed off the loopback โ
- Goal: The loopback host is the one thing in this run that is not real. Close that gap against an actual deploy to
dev.ourlantern.app. - This is a BEFORE arm, and saying so matters. #936 had not merged when this ran, so dev was running the OLD behaviour. Verified from the shipped artifact rather than from the branch:
/assets/index-mt21dn6d.BEEBa3kO.jscontains the purge (Cleared ... cachethree times,Reloading to update) and none of the fix's markers. This run is the bug confirmed on a real deploy, not the fix verified on one. - Steps:
- Before the deploy,
npm run e2e:sw-update-cost -- --prime. - After it lands,
npm run e2e:sw-update-cost -- --measure. - Read the hash diff FIRST.
- Before the deploy,
- Expected: per scenario 1, every asset URL moved.
Result: [x] pass, on all three things it was run to answer
Build: primed mt0ug5gl (2026-08-20 01:30Z) -> deployed mt21dn6d
(2026-08-20 21:32Z), deploy run 32419848071 off #934's merge
Actual: 1. THE INSTRUMENT NOW REPORTS THE TRUTH. "asset hashes changed:
16 of 16 (read from the DEPLOYED index.html)". On the previous
real deploy this same mode printed "NOT A HASH-CHANGING DEPLOY".
Scenario 1 is now confirmed on a real deploy, not just on two
local builds.
2. THE STALE SHELL, CAUGHT IN THE ACT. "19 of 19 assets in the
loaded document are NOT in the deployed index.html". The worker
answered the navigation from its old precache and the page ran
the PREVIOUS build. This is exactly the state that made the old
instrument compare the old asset set against itself, so the run
demonstrates both the mechanism and the false negative at once.
3. FAST, AND STALE. 703ms to interactive, all 20 assets served by
the worker, 0 from the network. A second open: 636ms, and still
19 of 19 stale. Scenario 2's finding reproduces on the real
origin.
Evidence: the --measure output above; deployed bundle inspected directly
Follow-up: see "what this run cannot show" belowWhich number to trust for which claim. The hash diff and the stale-shell count are exact: they are set comparisons against the deployed index.html fetched over the network, and nothing about the host affects them. The timing is not comparable to the loopback figures. This mode targets a remote origin, so bandwidth cannot be shaped server-side, and CDP throttling is attached to the page session and does not reach the service worker. The tool now says so in its own output. Use the loopback runs for cost (41,667ms to 705ms) and this run for existence: that a real deploy produces the condition.
What this run cannot show, and why that is itself a finding. It does not reproduce the 41.7s blank window, and it structurally cannot. versionCheck takes its baseline from the FIRST version.json fetch of the session, so a session that starts AFTER the deploy reads the new id on both sides, finds no mismatch, and never purges. The purge needs a session whose baseline predates the deploy.
That narrows who pays the 41.7s, and it is worth stating plainly: not the user who opens a closed app after a deploy (they get the fast, stale path measured above), but the user whose app was already running when the deploy landed. On a phone that is the ordinary case rather than the edge case, because backgrounding a PWA is how people leave an app, and visibilitychange is what fires the check on return. That last step is inference from the code path, not something this run measured, and it is scenario 10's job to settle it.
9b. Real deploy, AFTER arm โ
Goal: the same three questions against a dev running the fix.
Preconditions: a profile primed against the build preceding the deploy that carries #936. Primed against
mt21dn6d, 39 precached assets.Expected (CORRECTED 2026-08-21). The original wording of this line was wrong and is replaced rather than scored against, because a merged test plan is read as the spec and a wrong expectation in one is worse than no expectation at all.
It said: "16 of 16 URLs still move, and the loaded document is NOT stale, because the worker swaps once its new precache is ready."
The second half is false, and not because of the fix. The first open after a deploy serves the stale shell in BOTH versions, by design: the service worker answers the navigation out of the precache it already has, while the new worker installs in the background. The fix never claimed to change that, and a single-open
--measuresnapshot is the wrong instrument for the question this scenario is actually asking.What to expect instead:
- 16 of 16 URLs still move. That is #937, untouched by this fix.
- The first open IS still stale, in both versions. Not a finding.
- What must change is what the UPDATE costs when it lands, which needs the profile held open across the worker's install rather than opened once.
Verify: hold the profile open across the update; read the registration state directly (
getRegistrations()), never infer it from a stale DOM.
Result: [x] pass
Build: #936 merged 2026-08-20 21:50:43Z (70e6880), deployed mt22b2cn at
21:58:25Z. Fix confirmed present in the SHIPPED artifact before
measuring, not inferred from the branch:
/assets/index-mt22b2cn.on30BWG9.js has no purge markers and all
three fix markers.
Actual: single open, as predicted by the corrected expectation: 16 of 16
moved, 19 of 19 loaded assets stale, 678ms, 0 from network.
Held open across the worker update:
t=0 old shell, precache 43, 0 bytes, first paint 331ms
975ms new service worker found
8.6s reload fires: BLANK WINDOW 237ms, 0KB over the wire
to 5min page keeps the build it loaded with, precache 83
next open NEW build, precache cleaned to 43, 0 bytes, instant
Registration read directly: no installing, no waiting, active and
activated, controller present. Shape A ruled out on the real origin.
Against the loopback before-arm: 41,667ms and 1,924KB.
Evidence: /tmp artifacts did not survive the VM stop; the numbers above are the
record. Method is reproducible via `npm run e2e:sw-update-cost -- --measure`
plus a held-open profile.
Follow-up: the five minutes of old DOM was nearly reported as the fix stranding
the user. Reading the registration is what turned it into "an
already-loaded page keeps the build it loaded with, and heals on
reopen". Do not report a stale DOM as a stuck worker without reading
the registration.The gap this arm does NOT close, and it must not be quietly closed. The purge path has never run on the real origin, in either version. It structurally needs a session whose baseline predates the deploy, and versionCheck takes its baseline from the session's FIRST version.json fetch, so any session startable after a deploy reads the new id on both sides and never fires. So the headline 41,667ms to 705ms comparison is loopback-only, and the real-origin runs confirm everything around it. Scenario 10 case A is the only instrument that reaches it.
10. Operator confirms it on her own device (post-merge) โ
Goal: Every number in this run is emulated on a datacentre host. Her phone is the only instrument that can confirm the symptom is gone, AND the only one that can settle the reframing above.
Why the steps changed: the original version of this scenario asked whether it is slow once or slow every time. That question was built on the returning-user framing, which 9a undercut. The discriminating question is not how often, it is whether the app was already running when the deploy landed.
Steps, case A (the case the fix is for):
- Leave Lantern in the background on your phone. Do NOT force-close it, just switch away, which is the normal way you would leave it.
- Wait for a deploy to land (or ask, and it can be timed for you).
- Bring the app back to the foreground.
- Note whether it goes blank or reloads itself, and roughly for how long.
Expected after the fix: it comes back the way it always does. It may reload once, briefly, and that reload should feel instant rather than like a fresh start. No long blank wait, and no need to clear anything.
Expected BEFORE the fix, for reference: a blank reloading page on the order of 40 seconds on a weak connection.
Steps, case B (the control):
- Force-close Lantern completely.
- After a deploy has landed, open it fresh.
- Note the speed.
Expected, both before and after the fix: fast. This path never purges, so it should not be slow in either version. If THIS one is slow, the diagnosis here is incomplete and it reopens.
The one that would reopen everything: if you still see slow every time until you clear the cache manually, after the fix has deployed, then there is a second mechanism and this PR is incomplete rather than wrong. Nothing measured today reproduces "every time", and that gap is deliberately left open rather than argued away.
Result: [ ] pass [ ] fail [ ] blocked
Case A (backgrounded across the deploy):
Case B (force-closed, opened fresh):
Still slow every time until a manual clear?
Evidence:
Follow-up: