Vol. 07 · Dispatch2026-07-06

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.

by MetaWorldOS Engineering
IndexedDBWeb PerformanceThree.jsWebGLTypeScript

TL;DR — the bucket ACL isn’t public, so the game-assets API hands out signed URLs. Every request has a fresh Signature and Expires, so the browser HTTP cache — which keys on Request.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 stable sourceId, versioned by ${id}@${createdAt}, with GLTFLoader.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, new createdAt → cache miss then hit. correct.
  • asset removed from manifest → sweepStale evicts. correct.
  • asset re-uploaded to the same sourceId → id and createdAt unchanged → 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.

— Read Next —

推荐延伸阅读

更多关于 3D 渲染、物理仿真与网页游戏架构的深度技术解析

Draco

Draco compression, from an upload script in Node to a GLTFLoader in the browser

Sound Race's 13 GLBs are compressed with Draco on upload and inflated with a WASM decoder in the browser. This post walks the whole pipeline — the @gltf-transform + draco3dgltf server side, the "if compressed is bigger, keep original" safety valve, why the decoder files live in /public/draco/gltf/ instead of on a CDN, and the exact three lines of GLTFLoader wiring that connect them.

阅读全文
Three.js

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

阅读全文
Three.js

How Infinitown fakes an infinite city with 81 chunks and mod 9

The infinite-scrolling town in our Infinitown gamecenter port isn't procedurally generated — it's a Möbius carpet. A 9×9 pool of pre-built chunks maps onto a 9×9 grid of fixed container slots through modulo arithmetic, and camera drags rebind slots to different pool entries instead of spawning new geometry. This post walks the four moving parts (pool, containers, mapping, drag event) and explains why the whole system holds together with zero allocations at runtime.

阅读全文

订阅工程深潜文章 — 约每月一篇,没有营销邮件。