Skip to content

fix(background): keep a slow photo loading instead of replacing it - #351

Merged
kYem merged 7 commits into
mainfrom
kes/eng-116-glass-background-sometimes-never-loads-a-slow-stored-image
Sep 16, 2026
Merged

kYem merged 7 commits into
mainfrom
kes/eng-116-glass-background-sometimes-never-loads-a-slow-stored-image

Conversation

@kYem

@kYem kYem commented Sep 15, 2026

Copy link
Copy Markdown
Owner

The glass background sometimes never appeared until the tab was refreshed. Reported as "maybe a slow server"; the slow part is real but was largely self-inflicted.

Root cause

Three things conspired on a slow link:

Every fresh pick carried a t=Date.now() cache-buster — a leftover from source.unsplash.com, which returned a random photo per request. The curated ids that replaced it in 2024 never change, so the param only made each pick a URL nobody had ever requested. Measured against the CDN:

URL x-cache TTFB
no t= HIT, HIT, HIT 240ms
fresh t= MISS, MISS, MISS 621ms

A timeout was treated as a dead image. The stored photo was re-verified with an 8s limit, cancelled if it missed (img.src = ''), and replaced by up to three more cold picks at 8s each — then the page's own preload gave up at 5s. A good image that would have landed at 9s was thrown away at every step, and the tab sat on the gradient with nothing left to retry. A refresh worked because the first attempt had warmed the CDN.

Both total-failure paths logged at warn, invisible at the shipped level, so none of this left a trace.

Fix

The rule: a timeout means slow, an error means dead; only dead images are replaced — but it applies differently to the two kinds of photo.

  • A stored photo was verified when persisted, so on timeout it is kept while its download continues. This is the case the reporter hit, and the reason a refresh used to be needed.
  • A fresh pick must still be verified-or-throw, because three consumers (the refresh button, focus mode, and persistence itself) depend on that. It gets one generous 30s budget and no rival download is started beside it; a timeout ends the search.
  • preloadImage leaves the request running on timeout and rejects with a distinguishable ImageLoadTimeoutError.
  • App.tsx waits up to 60s for the final apply — the reveal deadline already unblocks the page at 1.5s, so this only has to outlast the link — and a result applies only if nothing newer has already applied, so a refresh and an effect load can overlap in either order.
  • Failure logs are error, name the URL, and never log a custom background (a data URL of the user's own picture) — through one helper shared by both sites.

Review

Five rounds ran, and each found a hole in the previous round's fix — including a regression vs main in round 2 and a privacy leak I introduced in round 3. The commits tell the story; the branch is squash-merged, so the history is here rather than in main.

Two tests were deliberately replaced rather than kept: one asserted the defect as intended behaviour, and one had put the slow part of a race in the wrong place and passed for the wrong reason.

Verification

2049 app tests, lint and type-check clean. Mutation-checked: reverting the cache-buster, the no-cancel timeout, the stored-keep rule, the verified-or-throw contract, the apply gate, the custom-background masking, or the plain-refresh increment each fails a test.

Not in this PR

Pre-existing, noted for follow-up: the first tab of the day on a link slower than ~10KB/s still cannot converge on a stored photo (the picker is random per tab); a toast on refresh failure; four surviving mutants on guards that are currently double-covered.

Fixes ENG-116

The glass background sometimes never appeared until the tab was refreshed. Three
things conspired, all on a slow link.

Every fresh pick carried a t=Date.now() cache-buster, a leftover from
source.unsplash.com which returned a random photo per request. The curated ids
that replaced it never change, so the param only made each pick a URL nobody
had ever requested: a full CDN miss and an origin transform, measured at three
times the latency of the cached form.

A timeout was treated as a dead image. The stored photo was re-verified with an
8s limit, cancelled if it missed, and replaced by up to three more cold picks at
8s each — then the page's own preload gave up at 5s. A good image that would
have landed at 9s was thrown away at every step, and the tab sat on the
gradient with nothing left to retry. A refresh worked because the first attempt
had warmed the CDN.

Both total-failure paths logged at warn, which is invisible at the shipped
level, so none of this left a trace.

A timeout now means slow, not dead: the request is left running, the stored
photo is kept, no rival download is started, and the page waits it out — the
reveal deadline already unblocks the UI. Only an error replaces an image.

Fixes ENG-116
Returning a still-loading URL from loadImageWithFallback on timeout broke a
contract three callers relied on. refreshBackground persisted it and the
refresh button applied it with no preload, so a slow pick blanked the current
photo to the gradient and credited a photographer nobody could see. A pick that
was slow to fail was persisted for the day and then kept on every tab, because
timeouts now meant keep. Focus mode queued it as if it were cache-warm.

The keep-on-timeout rule was right for the stored photo, which was verified
when stored, and wrong for a fresh one, which was not. A fresh pick now gets one
generous budget and is handed back only once it loads; a timeout ends the search
without starting a rival beside it, and the download continues to warm the
cache for the next tab.

Also guards the final apply against a slow load landing on top of a later
refresh — a window the longer timeout had widened from nil to a minute — and
stops logging a failure for a load the user had already switched away from.
The apply guard compared URLs claimed after each writer's own await, so a
request that started during that await could not supersede it, and each one
overwrote whatever the other had claimed meanwhile. A refresh in flight when the
category changed showed the old category's photo under the new setting and
suppressed the new load's own failure log — a case main got right by letting
the last write win. A monotonic id claimed before the first await settles it.

A fresh pick that merely outlasted its budget threw "all image sources failed",
which every downstream log repeated and which pointed diagnosis at a blocked
CDN. It now says the pick is still loading, with the timeout as its cause, and
a genuine total failure names every pick it tried.

Focus mode's own image layer kept the previous photo on error without a word,
which the stored-keep rule made reachable; it says so now.
The onerror log added to focus mode's image layer wrote the URL as given, and
in focus mode that URL is the user's own picture as a data URL whenever a
custom background is set — a corrupted stored value would have put its base64
in the console at the shipped level. App.tsx already masked the same case two
files away; both now go through one helper so they cannot drift apart.

A refresh that interrupted an effect load and then came back empty kept the
claim, so the load it displaced was dropped on success and unlogged on failure.
It hands the claim back now.

The abandoned-pick error names the URL, so a slow-link report can say which
photo, and the docs drop a "null only when every source fails" that stopped
being true once a single timeout ended the search.
Gating on "has a newer request started" dropped a load that finished while
a refresh was still out, then handed the claim back to a load that had
already gone. Gating on "has a newer request already applied" lets the
earlier result land and the later one overwrite it, which is what the screen
should show in either order, and needs no handback. Leaving glass counts as
a request that applied nothing, so a refresh still out cannot land after it.
Every refresh test had another request bump the counter first, so a refresh
that borrowed the previous id instead of claiming its own was dropped by the
gate on an idle page — the button silently did nothing — and the whole suite
stayed green. Also pins the one landing order the overlap tests had left out.
The 8s on a stored photo no longer decides anything — a timeout keeps it — so
it bounds how long callers wait before the URL is handed back still loading.
A named predicate says so and removes a catch whose tail was deliberately
empty. The fresh-pick loop drops a Math.min against a catalog shape that does
not exist.

The overlap tests advanced fake timers by thirty to forty seconds, firing a
few hundred component interval ticks per test for no reason; the delays only
have to be ordered. Both unsplash mocks spread the real module instead of
hand-copying exports, which also retires four isUnsplashUrl lines that no
longer reached the code under test.
@kYem
kYem merged commit bc13d08 into main Sep 16, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant