Model weights are the least cache-friendly-looking assets on the web: they are big, binary, and versioned. A quantized ONNX model is often tens of megabytes, and a naive fetch on every visit turns a fast on-device experience into a long spinner. But weights are also the most cache-friendly assets in one crucial sense: they are immutable. A released model file is not edited in place; a new model is a new URL. That single property makes cache-first the right default, and turns the whole problem into choosing the right strategy per asset type.
ReLU.chat ships a quantized all-MiniLM-L6-v2 ONNX model of roughly 22 MB, a small policy network, and pre-built BM25 IDF data. None of these change between visits, and all of them are expensive to re-fetch. This post walks through the strategies that make sense for each kind of ML asset.
The Four Caching Strategies
Service worker caching comes down to four strategies, and each fits a different asset class.
- Cache-first: serve from cache if present, fetch and cache on miss. Best for immutable, large assets: model weights, embedding indexes, precomputed IDF tables.
- Network-first: try the network, fall back to cache. Best for content that changes: the HTML shell, updated knowledge fragments.
- Stale-while-revalidate: serve the cached copy immediately, update it in the background. Best for medium-weight assets where freshness matters but speed matters more.
- Network-only: never cache. Best for anything that must be current, such as user-uploaded data.
A single fetch handler can route by URL pattern:
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.endsWith('.onnx')) {
event.respondWith(cacheFirst(event.request));
} else if (url.pathname.startsWith('/data/')) {
event.respondWith(staleWhileRevalidate(event.request));
} else {
event.respondWith(networkFirst(event.request));
}
});
The cache-first implementation is short:
async function networkFirst(request) {
const cache = await caches.open('relu-chat-v3');
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await cache.match(request);
if (cached) return cached;
throw error;
}
}
Network-first is the mirror image: try the network, and only on failure fall back to the cache. It trades a slower typical case (one network round trip instead of zero) for guaranteed freshness, which is why it belongs on the shell and on any content that changes between deploys.
async function cacheFirst(request) {
const cache = await caches.open('relu-chat-v3');
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}
Precache at Install, Cache at Runtime
There are two moments to fill the cache. At install, the service worker can precache everything the app needs to start: the HTML shell, the policy network, the BM25 IDF table, and — if the user is willing to wait — the 22 MB model. Precache is where the "instant on second visit" property comes from.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('relu-chat-v3').then((cache) =>
cache.addAll([
'/',
'/models/all-MiniLM-L6-v2.onnx',
'/models/policy.onnx',
'/data/bm25-idf.json',
])
)
);
});
Runtime caching fills the gaps: pages the user visits, knowledge fragments fetched on demand, any asset the precache list did not cover. cache.addAll fails atomically — if one URL in the list fails, nothing is cached — so keep the precache list to URLs you know resolve, and let large model files be added lazily or after the first successful fetch.
A practical pattern for a large model: do not block install on a 22 MB download over a slow connection. Instead, precache the small shell assets at install, and let the app trigger the model download with an explicit fetch that populates the runtime cache while a lightweight fallback answers the first turns. ReLU.chat's progressive loading does exactly this: the first turns are answered by a heuristic/BOW fallback while the model and policy weights load, and the service worker pre-caches those weights so the hot-swap to the full dense model never waits on the network.
Versioning and Cache Busting
Caches are keyed by request URL, so an updated model needs an updated URL or an updated cache name. The robust pattern is to include a version in the cache name and delete old caches on activate:
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== 'relu-chat-v3').map((key) => caches.delete(key))
)
)
);
});
For model files specifically, prefer content-hashed URLs (all-MiniLM-L6-v2.a1b2c3.onnx). A hash in the filename means a changed model is a new URL, so the old entry can stay in cache until activate cleans it, and cache-first stays correct with zero coordination.
Practical Notes for ML-Sized Assets
- Quota: Cache Storage is quota-bounded. A 22 MB model plus an embedding index can exhaust quota on low-end devices or in private browsing mode, where caches are often ephemeral. Handle
cache.matchmisses by falling back tofetchand showing a progress indicator instead of failing silently. - Cloning:
cache.putconsumes the response body, so always passresponse.clone()when you also return the response to the page. - Streaming: you can stream a large model into the cache while the first bytes are already being consumed, but in practice it is simpler to fetch fully, transfer the buffer to a worker, and cache the same response for the next visit.
- Do not cache private responses: if user data ever flows through the service worker, keep those requests network-only.
Key Takeaway
ML assets are immutable, large, and versioned — cache-first with content-hashed URLs is the right default for weights and indexes, while the shell and dynamic data get network-first or stale-while-revalidate treatment. Precache what the app needs to start, let progressive loading fill in the heavy model behind a lightweight fallback, version your caches, and respect quota. Done right, the second visit is instant and the first visit never waits on the model.