[perf] Fix SW image cache slowing cold thumbnail bursts (grid load regression)

"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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-02 14:59:21 -04:00
1 parent c20938b4aa
commit 84cec15483
2 files changed
+85 -16

No files matched your search

Vendored
+45 -16
View File
@@ -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);
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) => {
// 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(cache, request, response.clone()));
event.waitUntil(putImageInCache(request, response.clone()));
}
return response;
})
.catch((error) => {
if (cached) {
return cached;
}
throw error;
});
// 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;
+40
View File
@@ -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({