SW: stop cover-art burst from hard-failing on first load
A discography page fires 70+ cover-art requests at once. Routed through the service worker one-for-one, that burst overruns the browser's per-host connection pool (~6); the overflow fetches reject, and the cache-first strategy mapped each rejection to Response.error() — which Firefox surfaces as NS_ERROR_INTERCEPTION_FAILED, a hard, *uncached* image failure for that load. The page renders artless cards on first visit and only "heals" on reload (cached images shrink the burst). Fix the image path three ways: - Cap concurrent image fetches (semaphore, 6) so the burst queues instead of saturating the connection pool — the actual first-load fix. - Retry a rejected fetch once with a short backoff; most failures are transient connection-cap rejections that clear as the burst drains. - On final failure return a benign 504 instead of Response.error(), so a dead image degrades to a normal broken image (recoverable next nav) rather than NS_ERROR_INTERCEPTION_FAILED. Cache hits bypass the throttle entirely. Static-asset strategy is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0b6fddbc4
commit
01ed54f4f2
1 changed files with 61 additions and 7 deletions
|
|
@ -108,28 +108,82 @@ self.addEventListener('fetch', (event) => {
|
|||
});
|
||||
|
||||
|
||||
// ── image fetch throttle ───────────────────────────────────────────────
|
||||
// A discography page fires 70+ cover-art requests at once. Routed through the
|
||||
// SW one-for-one, that burst overruns the browser's per-host connection pool
|
||||
// (~6); the overflow fetches reject, and a cache-first strategy that maps a
|
||||
// rejection to Response.error() turns each into a hard NS_ERROR_INTERCEPTION_
|
||||
// FAILED — a permanently broken, *uncached* image for that load. (It only
|
||||
// "fixes itself" on reload because the images that did win the race are then
|
||||
// served from cache, shrinking the burst.) We cap how many image fetches the
|
||||
// SW runs at once so the rest queue instead of failing.
|
||||
const MAX_CONCURRENT_IMAGE_FETCHES = 6;
|
||||
let _activeImageFetches = 0;
|
||||
const _imageFetchQueue = [];
|
||||
|
||||
function _acquireImageSlot() {
|
||||
if (_activeImageFetches < MAX_CONCURRENT_IMAGE_FETCHES) {
|
||||
_activeImageFetches++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => _imageFetchQueue.push(resolve));
|
||||
}
|
||||
|
||||
function _releaseImageSlot() {
|
||||
const next = _imageFetchQueue.shift();
|
||||
// Hand the slot straight to the next waiter (count stays put); only
|
||||
// decrement when nobody is queued.
|
||||
if (next) next();
|
||||
else _activeImageFetches--;
|
||||
}
|
||||
|
||||
async function _fetchWithRetry(request, retries = 1, backoffMs = 200) {
|
||||
try {
|
||||
return await fetch(request);
|
||||
} catch (err) {
|
||||
if (retries <= 0) throw err;
|
||||
// Most failures here are transient connection-cap rejections that
|
||||
// clear as the burst drains — one short retry recovers the bulk.
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||
return _fetchWithRetry(request, retries - 1, backoffMs);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── strategies ───────────────────────────────────────────────────────
|
||||
|
||||
async function _cacheFirst(request, cacheName) {
|
||||
// Cache lookup first — a hit must never consume a fetch slot. If the cache
|
||||
// layer itself is unavailable, fall through to a throttled network fetch.
|
||||
let cache = null;
|
||||
try {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
} catch (err) {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
const response = await fetch(request);
|
||||
await _acquireImageSlot();
|
||||
try {
|
||||
const response = await _fetchWithRetry(request);
|
||||
// Only cache successful, opaque-OK responses. Don't cache 404s
|
||||
// / 500s — would pin a bad placeholder for the lifetime of the
|
||||
// cache version.
|
||||
if (response && (response.ok || response.type === 'opaque')) {
|
||||
if (cache && response && (response.ok || response.type === 'opaque')) {
|
||||
// Clone before .put — body is consumed otherwise.
|
||||
cache.put(request, response.clone()).catch(() => { /* quota / disk full */ });
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
// Network failure with no cache hit — let the browser surface
|
||||
// its standard offline / error UI (returning Response.error()
|
||||
// is equivalent to letting the fetch reject naturally).
|
||||
return Response.error();
|
||||
// Both attempts failed (offline / connection cap / CDN error). Return a
|
||||
// benign 504 rather than Response.error(): a network-error response
|
||||
// from a SW surfaces as NS_ERROR_INTERCEPTION_FAILED in Firefox, which
|
||||
// hard-fails the <img>. A plain 504 just yields a broken image that
|
||||
// recovers on the next navigation once the cache is warm.
|
||||
return new Response('', { status: 504, statusText: 'Image fetch failed' });
|
||||
} finally {
|
||||
_releaseImageSlot();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue