When signed URLs break your browser cache, put the bytes in IndexedDB
Signed OSS URLs rotate every request, so the disk cache never matches. Sound Race redownloaded 13 GLBs on every visit until we sat an IndexedDB layer in front of GLTFLoader. Here's the code, why Cache Storage doesn't help, and one invalidation edge case we're leaving as tech debt.
TL;DR — the bucket ACL isn’t public, so the game-assets API hands out signed URLs. Every request has a fresh
SignatureandExpires, so the browser HTTP cache — which keys onRequest.url— never matches, and Sound Race’s 13 GLBs redownload every visit. Cache Storage has the same problem (also URL-keyed). The fix is a small IndexedDB store keyed on the stablesourceId, versioned by${id}@${createdAt}, withGLTFLoader.parseAsync(buffer, '')as the seam.Code:
src/features/gamecenter/games/sound-race/game/render/assetCache.ts
The way you find out you have this problem is that DevTools’ Network tab keeps showing “(disk cache)” as blank for the same file, over and over, on the same profile. Every column is filled in — status 200, ~80 MB body, ~4 s over your home wifi — except Size / Time from cache. That column stays empty because the URL keeps changing. Signed OSS URLs are a query-string identity: Signature=…&Expires=…. Chrome’s disk cache is keyed on the request URL. No match, no reuse.
Nothing you can do at the HTTP layer fixes this. Cache-Control: public, max-age=31536000 tells the browser it’s allowed to cache the response — and it does — but lookup is still URL-keyed, and every next fetch is a different URL. There’s no header you can Vary on that would collapse those two URLs into the same cache entry, because there’s no header carrying the identity signal (the query string) that you’d want to strip.
The one thing that would fix it at the HTTP layer is public URLs, and that’s coming — the bucket policy needs a oss:GetObject grant scoped to game-assets/sound-race/*, and once that’s in, the disk cache does the right thing on its own. But the same bucket also holds prefixes we don’t want world-readable, and the audit around scoping that policy correctly is a bigger fight than the caching regression is worth. So: work around it in the client, keep the option to switch to public URLs later, don’t design anything that fights an eventual public-URL setup.
Why not Cache Storage
The instinct is to reach for caches.open(...) here. It’s the modern API, it’s async, it’s the piece of infrastructure Service Workers are built on. It also has exactly the same problem: caches.match(request) and cache.put(request, response) both key on the Request, which keys on URL. Different tribe, same disease.
You can work around this by stripping the query string before every put/match — turn .../pickup-floppy.glb?… into .../pickup-floppy.glb and use that as the request key. It works. It’s also more code than the IDB version, and it stitches together Response objects (which own streams that need consuming exactly once), Request cloning, and the whole caches.match(request, { ignoreSearch: true }) option that only some browsers implement well. If you’re already storing Response objects for other reasons, sure. Otherwise the IDB path is shorter.
The store
The manifest returns each asset with a set of stable identifiers — sourceId, id, createdAt — plus the ephemeral signed model URL. The identifiers give us a natural key + version.
// src/features/gamecenter/games/sound-race/game/render/assetCache.ts
const DB_NAME = 'sound-race-assets'
const DB_VERSION = 1
const STORE = 'blobs'
interface CachedEntry {
sourceId: string
version: string
buffer: ArrayBuffer
storedAt: number
}
export async function getCached(sourceId: string, expectedVersion: string) {
const entry = await withStore<CachedEntry | undefined>(
'readonly',
(store) => store.get(sourceId) as IDBRequest<CachedEntry | undefined>,
)
if (!entry) return null
if (entry.version !== expectedVersion) return null
return entry.buffer
}
export async function putCached(sourceId: string, version: string, buffer: ArrayBuffer) {
const entry: CachedEntry = { sourceId, version, buffer, storedAt: Date.now() }
await withStore('readwrite', (store) => store.put(entry))
}
withStore is a 30-line wrapper around IDB’s callback API — open, transaction, request, resolve, close. It returns null on any failure so callers treat “IDB unavailable” (Safari private mode with quota=0 is the classic case) exactly the same as “cache miss”. Everything downstream still works, we just re-fetch every time.
Slotting it into GLTFLoader
The seam is GLTFLoader.parseAsync(buffer, path). It takes the raw ArrayBuffer directly. If we already have the bytes we call parseAsync; if not, we fetch and then call parseAsync. Same code path from that point on.
async function loadOne(loader: GLTFLoader, url: string, slot: AssetSlot, version: string) {
let buffer = await getCached(slot.sourceId, version)
if (buffer) {
console.info(`[sound-race] cache hit ${slot.sourceId}`)
} else {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status} loading ${slot.sourceId}`)
buffer = await res.arrayBuffer()
void putCached(slot.sourceId, version, buffer.slice(0))
}
const gltf = await loader.parseAsync(buffer, '')
// ...auto-fit, material fixup, return the group.
}
Two things I got wrong on the first pass and had to fix.
First: buffer.slice(0). GLTFLoader hands the buffer off to the Draco decoder worker via postMessage([buffer.buffer], [buffer.buffer]) — a transfer, not a copy. If we hand the same buffer to IDB and to the loader, the transfer neuters our IDB write mid-flight. slice(0) is a defensive copy so parse and write don’t fight over the same bytes. Yes, we’re paying for the copy — but this is the loading screen, not the render loop, and quota-aware IDB writes are the sort of thing you don’t want racing anything.
Second: the write is fire-and-forget. void putCached(...) — no await. Under quota pressure Chrome will sit on an IDB write for hundreds of milliseconds, occasionally longer. The GLBs have to reach the parser now; the cache write is opportunistic. If it fails, the next visit is a miss, which is exactly what happens today anyway.
Sweeping stale entries
Version-string mismatch handles “asset row was replaced upstream” — but not “asset row was deleted from the manifest entirely”. Those bytes would sit in IDB forever, or at least until quota pressure evicts the whole DB. So on every fresh manifest we walk the store with a cursor and drop anything that doesn’t line up:
const versionMap = new Map<string, string>()
for (const a of manifest) {
if (a.model) versionMap.set(a.sourceId, `${a.id}@${a.createdAt}`)
}
void sweepStale(versionMap) // fire-and-forget
Cursor-based iteration is the important part — pulling every entry into memory to filter it would defeat the point of IDB for a store that could grow to hundreds of MB. The cursor version deletes in place:
export async function sweepStale(keep: Map<string, string>): Promise<void> {
const db = await openDb()
if (!db) return
return new Promise((resolve) => {
const tx = db.transaction(STORE, 'readwrite')
const cursorReq = tx.objectStore(STORE).openCursor()
cursorReq.onsuccess = () => {
const cursor = cursorReq.result
if (!cursor) { db.close(); return resolve() }
const entry = cursor.value as CachedEntry
const wanted = keep.get(entry.sourceId)
if (wanted === undefined || wanted !== entry.version) {
cursor.delete()
}
cursor.continue()
}
cursorReq.onerror = () => { db.close(); resolve() }
})
}
Best-effort. If the transaction aborts halfway through, some stale entries stick around until the next successful sweep. The browser evicts the whole DB before that becomes user-visible.
The invalidation hole
Writing this post is when I noticed the actual bug in what we shipped. The version string is ${id}@${createdAt}. Both fields are set once when the DB row is first inserted. The upload script does prisma.gameAsset.upsert({ where: { sourceId } }) — so re-uploading a GLB with the same sourceId updates the existing row rather than creating a new one, which means:
- new asset added → new
id, newcreatedAt→ cache miss then hit. correct. - asset removed from manifest →
sweepStaleevicts. correct. - asset re-uploaded to the same
sourceId→idandcreatedAtunchanged → we serve the old bytes indefinitely. wrong.
Prisma has updatedAt in the schema (@updatedAt), and it does bump on every update. But the API’s select: clause doesn’t include it, so the client can’t see it. Two-line fix — add updatedAt: true to the select, switch the version to ${id}@${updatedAt} client-side.
I didn’t do it in the same PR because our current workflow always creates a fresh row and soft-deletes the old one when we replace an asset (the pipeline’s sourceId includes a version suffix), so the update path isn’t exercised. When we start doing in-place replacements, this cache will start serving stale bytes and someone will need to make the fix. That day this article becomes a bug report.
Instrumentation
Wired a GA event alongside both branches so hit rate is visible in the field:
if (buffer) {
trackSoundRaceAssetCache({ result: 'hit', source_id: slot.sourceId })
} else {
const res = await fetch(url)
buffer = await res.arrayBuffer()
void putCached(slot.sourceId, version, buffer.slice(0))
trackSoundRaceAssetCache({ result: 'miss', source_id: slot.sourceId })
}
The single-user local repro is unambiguous — first visit fetches all 13 GLBs, second visit fetches only the manifest — but real-world hit rates depend on how often browsers evict IDB under memory pressure, which we can’t guess without the field data. If it turns out to be lower than we like, that’s the next signal to actually go do the bucket-policy change and let public URLs + the HTTP cache carry the load.
Which is fine, because the whole thing was designed to coexist with public URLs. IDB hit → skip network entirely. IDB miss → fetch, which itself may hit the disk cache if URLs are stable. Disk-cache miss → the network. Layers stack; nothing fights.