Take the service worker off the cover path, and cut cover art to 384px

Search was still stalling for seconds a keystroke. A trace across five keystrokes said
the renderer main thread was blocked for 9.4 s in a single task with *zero* V8 samples
inside it - no JavaScript ran at all. What ran instead, on the worker pool during those
same 9.4 s: ImageDecodeTask 7.6 s, RasterTask 1.7 s, and only 229 ms of actual "Decode
Image". The main thread was waiting on cover bytes, not computing anything.

Two causes, and the first one was mine. The stale-while-revalidate handler added earlier
today made every cover do a Cache Storage read *plus* a network fetch *plus* a cache
write, all serialised through one worker thread. Measured with
Network.setBypassServiceWorker: worst keystroke 5580 ms through the worker against
461 ms without it. So covers no longer go through the worker at all. That costs nothing
here: this worker only registers on localhost or over HTTPS, which on this setup is the
kiosk on the device itself, where the backend is the same machine and a cache lookup is
strictly more work than asking for the file. The shell caching, which is what makes it
installable, stays.

The second is that decode cost goes with pixel count, and 640 px was headroom for a
tablet at devicePixelRatio 2 that nobody had asked for. A browse grid paints a card
132 px wide; the largest any screen asks for is 340. At 384 px, typing "conni" over 343
albums, keydown to painted, three runs:

  before   3735,  270, 5580,  88,  57 ms
  after     873,   75,   17,  24,  26 ms

`shrink_cover` never scales art up, so dropping the limit cannot be applied by
re-reading the cache - only by going back to the original art. _INDEX_VERSION 6 does
that: the index is discarded and every album is scanned again through store_cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 18:31:57 +02:00
parent ebab03b700
commit 83d16e0058
2 changed files with 40 additions and 43 deletions

View File

@@ -4,18 +4,9 @@
// cached answer for any of those would be a lie.
const SHELL = "musikdelphin-shell-v1";
// v2: the backend now downscales cover art to a 640 px long edge on its way into the
// cache (MAX_COVER_PX in library/cache.py). The URL of a cover does not change when its
// contents do, and the handler below is cache-first, so every client that had ever
// loaded a cover went on serving the old 3000 px one from disk - a 9-megapixel decode
// per card, which a trace on musicdolphin showed costing ~1.5 s each and dwarfing
// everything else the page did. Renaming the cache is what retires them: `activate`
// deletes every cache that is not one of these two.
//
// So: change how covers are produced, bump this name. "Immutable per album id" is true
// of which album a cover belongs to, not of the bytes.
const COVERS = "musikdelphin-covers-v2";
// There is no cover cache any more; `activate` below deletes every cache that is not
// SHELL, which retires the two generations of cover cache this worker used to keep.
self.addEventListener("install", (event) => {
event.waitUntil(
caches
@@ -39,7 +30,7 @@ self.addEventListener("activate", (event) => {
caches
.keys()
.then((names) =>
Promise.all(names.filter((n) => n !== SHELL && n !== COVERS).map((n) => caches.delete(n))),
Promise.all(names.filter((n) => n !== SHELL).map((n) => caches.delete(n))),
)
.then(() => self.clients.claim()),
);
@@ -52,31 +43,22 @@ self.addEventListener("fetch", (event) => {
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Cover art: stale-while-revalidate. The cached copy is served straight away, which
// is what keeps a grid of album art off the network entirely, and a fresh copy is
// fetched behind the page and put back for next time.
// Cover art is NOT cached here, and that is deliberate. This worker only ever
// registers on localhost or over HTTPS (see the README), which on this setup means
// the kiosk on the device itself - where the backend is the same machine, and a
// Cache Storage lookup plus a revalidating fetch plus a cache write is strictly more
// work than just asking for the file.
//
// It was plain cache-first, which is subtly wrong in a way that cost a long afternoon:
// the bytes behind a cover URL do change (the backend reprocesses art), and
// cache-first on a URL that never changes means a client can serve a stale cover for
// ever. Revalidating in the background is the cheap way to be both fast and eventually
// right, and it needs no version bump when art is reprocessed.
if (url.pathname.startsWith("/api/albums/")) {
event.respondWith(
caches.open(COVERS).then(async (cache) => {
const hit = await cache.match(request);
const fetching = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
// Offline, or the mouse is off: a cached cover is still better than none.
.catch(() => hit);
return hit ?? fetching;
}),
);
return;
}
// It was measured, after a stale-while-revalidate version of this handler made
// searching visibly worse. Typing "conni" over a 343-album library, keydown to
// painted, worst keystroke: 5580 ms through this worker against 461 ms with
// Network.setBypassServiceWorker on. The main thread was not running any JavaScript
// during those stalls - it sat waiting while ImageDecodeTask blocked on bytes this
// worker was serialising through a single thread.
//
// If the player ever moves behind a TLS proxy for the tablet, revisit: caching covers
// is worth something over wifi, and would want a budget rather than every cover on
// every load.
// Everything else under /api is live state. Never cache it, never serve it stale.
if (url.pathname.startsWith("/api/")) return;