From 84cec15483df561334b7b0abe28c004dff6fb9d1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:59:21 -0400 Subject: [PATCH] [perf] Fix SW image cache slowing cold thumbnail bursts (grid load regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Recently Added" grids loaded visibly slower after the image cache shipped. The feed's data path was untouched — the regression was the thumbnails: the SW handler had three compounding costs on a cold cache: 1. The network fetch waited behind caches.open + cache.match, delaying every thumbnail's download start. 2. caches.open ran per request instead of once. 3. trimImageCache ran a full keys() enumeration after EVERY put — a grid burst of dozens of thumbnails did dozens of O(n) enumerations, all contending with the cache.match calls of later images. Fix: start the network fetch immediately (cold path is now as fast as no SW; a warm cache still serves the cached copy for first paint), memoize the cache open, and amortize trims (every 20 puts). SW_VERSION bumped so testers pick it up. Tests: sw-image-cache (7 — adds a regression test that gates cache.match behind a promise and asserts the download starts anyway; all prior behaviors re-verified against the real sw.min.js). Lint + build green. Co-Authored-By: Claude Opus 4.8 --- sw.min.js | 75 ++++++++++++++++++++++++----------- tests/sw-image-cache.test.mjs | 40 +++++++++++++++++++ 2 files changed, 92 insertions(+), 23 deletions(-) diff --git a/sw.min.js b/sw.min.js index caafee44..204942f7 100644 --- a/sw.min.js +++ b/sw.min.js @@ -1,7 +1,7 @@ (() => { "use strict"; - const SW_VERSION = "bitvid-sw-2026-07-02"; + const SW_VERSION = "bitvid-sw-2026-07-02.1"; const SW_METADATA = Object.freeze({ version: SW_VERSION, runtimePathPrefix: "/webtorrent/", @@ -16,6 +16,23 @@ // cache + the dist cache-purge pipeline). Bump the version to discard. const IMAGE_CACHE = "bitvid-images-v1"; const IMAGE_CACHE_MAX_ENTRIES = 300; + // Trim is O(entries) (a full keys() enumeration), so amortize it instead of + // running it after every put — a cold grid load puts dozens of thumbnails in a + // burst, and per-put trims made that burst contend with the cache.match calls + // of later images (the "grid loads slowly" regression). + const IMAGE_CACHE_TRIM_EVERY = 20; + let imageCachePromise = null; + let imagePutsSinceTrim = 0; + + function openImageCache() { + if (!imageCachePromise) { + imageCachePromise = caches.open(IMAGE_CACHE); + imageCachePromise.catch(() => { + imageCachePromise = null; + }); + } + return imageCachePromise; + } function isCacheableImageRequest(request) { if (!request || request.method !== "GET") { @@ -50,14 +67,20 @@ } } - async function putImageInCache(cache, request, response) { + async function putImageInCache(request, response) { try { + const cache = await openImageCache(); await cache.put(request, response); - await trimImageCache(cache); + imagePutsSinceTrim += 1; + if (imagePutsSinceTrim >= IMAGE_CACHE_TRIM_EVERY) { + imagePutsSinceTrim = 0; + await trimImageCache(cache); + } } catch (error) { // Quota pressure (opaque responses are padded heavily for quota // accounting) or storage failure: drop the whole image cache and keep // going — the page still got its network response. + imageCachePromise = null; try { await caches.delete(IMAGE_CACHE); } catch (cleanupError) { @@ -68,31 +91,37 @@ async function handleImageRequest(event) { const request = event.request; - const cache = await caches.open(IMAGE_CACHE); - const cached = await cache.match(request, { ignoreVary: true }); - const refresh = fetch(request) - .then((response) => { - // Cache good responses; opaque (cross-origin no-cors) counts as good — - // its status is unreadable, and these are exactly the images we target. - if (response && (response.ok || response.type === "opaque")) { - event.waitUntil(putImageInCache(cache, request, response.clone())); - } - return response; - }) - .catch((error) => { - if (cached) { - return cached; - } - throw error; - }); + // Start the network fetch IMMEDIATELY — never delay a download behind cache + // I/O. On a cold cache this makes the SW path as fast as no SW at all; on a + // warm cache the cached copy below still wins the race for first paint. + const network = fetch(request).then((response) => { + // Cache good responses; opaque (cross-origin no-cors) counts as good — + // its status is unreadable, and these are exactly the images we target. + if (response && (response.ok || response.type === "opaque")) { + event.waitUntil(putImageInCache(request, response.clone())); + } + return response; + }); + // Mark handled so a network failure can't surface as an unhandled rejection + // while we're still consulting the cache; the original promise stays + // observable for the no-cache path below. + network.catch(() => {}); + + let cached = null; + try { + const cache = await openImageCache(); + cached = await cache.match(request, { ignoreVary: true }); + } catch (error) { + // Cache unavailable — plain network behavior. + } if (cached) { - // Keep the background refresh alive after we respond. - event.waitUntil(refresh.catch(() => {})); + // Serve the cached copy instantly; the refresh continues in the background. + event.waitUntil(network.catch(() => {})); return cached; } - return refresh; + return network; } let cancelled = false; diff --git a/tests/sw-image-cache.test.mjs b/tests/sw-image-cache.test.mjs index 753af501..f506dfa0 100644 --- a/tests/sw-image-cache.test.mjs +++ b/tests/sw-image-cache.test.mjs @@ -233,6 +233,46 @@ test("activate wipes other caches but preserves the CURRENT image cache", async ); }); +test("the network fetch starts immediately — never serialized behind cache I/O", async () => { + // Regression: the first version awaited caches.open + cache.match BEFORE + // starting the download, so a cold thumbnail burst loaded visibly slower than + // with no SW at all ("Recently Added fetching slowly"). + let fetchStarted = false; + let releaseMatch; + const matchGate = new Promise((resolve) => { + releaseMatch = resolve; + }); + + const { listeners, sandbox } = loadSw({ + fetchImpl: async () => { + fetchStarted = true; + return makeResponse("net-1"); + }, + }); + // Make cache.match hang until released — the download must not wait for it. + const realOpen = sandbox.caches.open.bind(sandbox.caches); + sandbox.caches.open = async (name) => { + const cache = await realOpen(name); + return { + ...cache, + async match(request) { + await matchGate; + return cache.match(request); + }, + }; + }; + + const event = makeFetchEvent(IMG); + listeners.get("fetch")(event); + await Promise.resolve(); // let the handler take its first steps + assert.equal(fetchStarted, true, "download began while cache.match was still pending"); + + releaseMatch(); + const response = await event.response; + assert.equal(response.body, "net-1"); + await Promise.all(event.pending); +}); + test("opaque (cross-origin no-cors) responses are cached; failed basic responses are not", async () => { let mode = "opaque"; const { listeners, cacheStorage } = loadSw({