Topic 12.4
Downloading and Caching Models
In one line
A browser model is a large download. Show honest progress, save the files in the Cache API so the next visit is instant, handle a full disk and an interrupted download, and put a version in the cache name so old files can be replaced.
Think of it like this
Installing a big game. The first time there is a long download with a progress bar. If the connection drops at 40 percent, a good installer carries on from 40 percent. Next time you start the game, there is no download at all. If the disk is full, it tells you what to delete. And when there is an update, the old files are replaced. A model in the browser needs the same care: progress, resume, space, version.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Content-Length
- A response header that gives the size of the body in bytes. It is what lets you show a percentage.
- Cache API
- A browser store for request and response pairs, used for files like model weights. Methods:
caches.open,put,match. - Quota
- The amount of storage the browser allows a site. Saving past it throws
QuotaExceededError. - Range request
- An HTTP request for part of a file, such as
Range: bytes=400-. The server answers206if it supports it. - Resume
- Continuing a download from where it stopped instead of starting over.
- Persistent storage
- A request,
navigator.storage.persist(), asking the browser not to clear the site's data when space runs low. - Cache key / cache name
- The name under which files are stored. Putting a version in it lets you replace old files.
- Indeterminate progress
- A progress display that shows activity without a percentage, used when the total is unknown.
Step by step
01Count the bytes
The first live example serves a 1000-byte "model" and downloads it with a stream reader. Each chunk adds to the received bytes and the code prints the percentage when it moves into a new quarter. The server here is a serve() handler: in a real app this is a static file on your server or CDN, and the browser does the same job.
02Survive a break
The server breaks the first response after 400 bytes. The reader throws, and the code does the important thing: it keeps the bytes it already has. It then asks again with Range: bytes=400- and the server answers with status 206. The output shows starting with status 200, then resuming with status 206.
The final line compares every byte with the original. A resume that appends to the wrong position is a common mistake and gives a corrupt file. Real systems also check a hash of the finished file.
03Save it, with a version
The second example is a small stand-in for the Cache API with a quota, since the real one is unavailable in the preview. The cache name includes v2. It first creates an old models-v1 cache, deletes it with cleanOldVersions(), then saves a model (network, now cached), reads it back (cache), and tries a file too big for the quota.
That last file is not saved, and the app still gets its bytes: it uses the model from memory for this visit and says it could not keep a copy. A full disk should cost the user a slower next visit, not the feature.
04The real browser calls
In a real page, the same ideas use these calls. They are not live here: in the sandboxed preview the Cache API throws a SecurityError and navigator.storage.estimate() rejects with a TypeError, so a real app guards each of them with try … catch and treats a failure as "no cache, download again".
const cache = await caches.open("models-v2");
const hit = await cache.match("/models/intent.bin");
const { usage, quota } = await navigator.storage.estimate(); // both in bytes
const persisted = await navigator.storage.persist(); // true if the browser agrees
try {
await cache.put("/models/intent.bin", response);
} catch (e) {
if (e.name === "QuotaExceededError") { /* use the model without saving it */ }
else throw e;
}05The progress screen
Show: the model name and size, a progress bar with a percentage, a Cancel button, and what happens next ("This is saved on your device, so next time it is instant"). On failure show the reason in plain words ("Download interrupted. Retry") and keep the bytes so Retry resumes. Make the bar a real <progress> element or give it role="progressbar" with aria-valuenow, so assistive technology can read it.
Try it yourself
- 1
Break it twice
In the first example change
requests === 1 ? 400 : Infinitytorequests <= 2 ? 400 : Infinity. The second attempt breaks too (at 400 more bytes, from where it resumed).The code has no loop, so it throws. Wrap the download in a loop that retries up to 3 times. Notice that each retry resumes from the bytes already kept.
- 2
Bump the version
In the second example change
MODEL_VERSIONto"v3"and run it again. Thev1cache is still removed bycleanOldVersions().Add a line that creates a
models-v2cache before the cleanup. It is removed too: only the current name survives. - 3
Progress when the size is unknown
In the first example the total is a constant. A real server may send no
Content-Length(and for a compressed response the header gives the compressed size, while the stream delivers the unpacked bytes, so the two can disagree). Pretendtotalis0:received.length / 0isInfinity, and the code would printInfinity%.Write
const label = (got, total) => total > 0 ? Math.floor((got / total) * 100) + "%" : got + " bytes so far";and call it with(400, 1000)and(400, 0). You get40%and400 bytes so far. On the page, that second form is an indeterminate bar: it shows that something is happening, and never invents a percentage.
Code & diagrams
The "server" is a serve() handler that supports the Range header and deliberately breaks the first response. The file is 1000 bytes, only so the example stays small.
Preview
Console
Nothing logged yet.Expected console output
starting with status 200
progress: 10%
progress: 30%
interrupted at 400 of 1000 bytes: network error
resuming with status 206
progress: 50%
progress: 80%
progress: 100%
complete and identical: trueThe real Cache API is blocked in this sandboxed preview, so the example uses a stand-in with the same method names and a size limit. The page shows whether the real one was usable; the console shows only that the check did not throw.
Preview
Console
Nothing logged yet.Expected console output
the check itself never throws: true
caches before: models-v1
caches after cleanup:
{"from":"network, now cached","bytes":400}
{"from":"cache","bytes":400}
{"from":"network only","bytes":900,"warning":"could not save for next time"}
bytes stored: 400Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Resume without asking for the rest
In the first example change the fetch line so it always sends {} as options (no Range header), then run it.
Break #2
Forget that saving can fail
In the second example change if (e.name === "QuotaExceededError") return to if (false) return.
Myth vs fact
Myth
The browser keeps everything I save forever.
Fact
Storage can be cleared by the user or by the browser when space is short, unless persistent storage was granted. Always handle a cache miss.
Myth
A progress bar is optional for a few megabytes.
Fact
On a slow phone connection even 20 MB takes minutes. A frozen screen looks like a bug and users leave.
Myth
If the download breaks, just start again.
Fact
That wastes data and time. Keep the bytes and resume with a Range request, or split the model into shards.
Myth
The cache updates itself when I deploy a new model.
Fact
A cached file keeps matching its URL. Put a version in the URL or cache name and remove old caches.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Prefer many shards to one huge file. Several smaller files can be downloaded in parallel, can each be saved or retried on their own, and a failure costs one shard, not the whole model. Check how your library and model host split files.
- ▸
Hash what you download. A corrupt model file may load and give wrong answers silently. A checksum (for example SHA-256 through
crypto.subtle.digest) of the finished file, compared with a value you publish, catches truncation, bad resumes and tampering. - ▸
Ask before downloading. A model is a cost to the user: data, battery and disk. Show the size and ask, and honour the browser's data-saver signal where available. Never start a big download in the background of an unrelated page.
- ▸
Plan for eviction. Even a persistent cache can be deleted by the user's "clear site data". The first-run flow should be the same as the flow after eviction: no special errors, just a download.
Remember this
- 1
The problem this solves: A page that starts a download of tens of megabytes and shows nothing looks frozen. A download that restarts from zero after every hiccup wastes the user's data plan. A model that downloads again on every visit makes the feature feel slow and costs you bandwidth. And a model file that never gets replaced keeps old answers forever. This topic covers all four.
- 2
Size first. The weights of a model are the bulk of the download, and sizes differ enormously between models and precisions (Topic 12.2). Do not guess: read the model card and the file list, or measure. For a sense of scale, this site's own search lab uses an 8-bit version of
all-MiniLM-L6-v2that its source comments describe as about 23 MB. Larger chat models are far bigger. Tell the user the size before starting, and ask first on a metered connection. - 3
Progress needs a total. A progress bar needs two numbers: bytes received and bytes expected. The response header
Content-Lengthgives the expected size when the server sends it. You read the body as a stream (Topic 0.4) and count the bytes of each chunk. If there is no total (compressed responses may not give one), show an indeterminate bar instead of lying with a percentage. Libraries wrap this: Transformers.js gives aprogress_callbackwithfile,loadedandtotalper file, and a model has several files, so you add them up (Topic 12.2, practice). - 4
The Cache API is a browser store for request-and-response pairs, made for exactly this:
const cache = await caches.open(name), thencache.put(url, response)to save andcache.match(url)to look up. Files in it survive a page reload and a browser restart. Transformers.js uses it by default (env.useBrowserCache, with the cache name fromenv.cacheKey, default"transformers-cache"), and WebLLM defaults to a"cache"backend as well (it can also use IndexedDB or OPFS). The Cache API needs a secure context, and it can be blocked in other ways: in the sandboxed frame of this page, even readingtypeof cachesthrows aSecurityError. So the second live example asks insidetry … catchand shows the answer on the page. - 5
Storage has limits. The browser gives each site a quota, which depends on the device and the free disk space.
navigator.storage.estimate()returns an object withusageandquotain bytes. MDN calls both values approximate (the quota is a conservative estimate, and it can differ between sites), so treat them as a hint. Saving past the quota fails with aQuotaExceededErrorin practice; MDN's Cache page does not name the error, so handle any rejectedput(), and test for the name only to give a better message. The browser may also clear a site's storage when the device is short of space, unless the site has asked for persistent storage withnavigator.storage.persist(), which the browser may grant or refuse. MDN adds that when a browser clears Cache API data it generally clears all of an origin's data or none of it, so a model can vanish together with everything else your site saved. So: check the estimate before a big download, catch the quota error when saving, and treat the cache as something that can disappear. - 6
Interrupted downloads. Networks drop. HTTP lets a client ask for part of a file with the
Rangeheader (Range: bytes=400-), and the server answers206 Partial Contentwith aContent-Rangeheader, if it supports ranges. The first live example does this by hand: a download breaks at 400 of 1000 bytes, the code keeps those bytes, asks forbytes=400-, and ends with a file identical to the original. Whether your library resumes by itself varies, so check it, and for very large files consider splitting the model into shards you download and store separately. - 7
Versioning. Put the model version in the cache name (
models-v2) or in the file URLs. A new version is then a new cache; after it is saved, delete caches that start withmodels-but are not the current one. Without a version, an updated model on your server is never fetched, because the cached old one always matches. The second example shows the cleanup.
Explain it without notes
How do you show honest progress for a model download, and what if the size is unknown?
What is the Cache API used for here, and what limits it?
How does a resumable download work, and what must the server support?
How do you make sure users get a new model version after a deploy?
Practice
Wrap the first example's download in a loop that retries up to 3 times, resuming each time. Throw "giving up" if it still fails.
Write ensureSpace(neededBytes) for the real browser: use navigator.storage.estimate() and return false if quota - usage is smaller than neededBytes.
Trade-offs
- ↔
Caching makes every visit after the first instant, at the cost of the user's disk space and the need to version and clean up. Not caching is simpler but makes each visit pay the download.
- ↔
Resuming saves the user's data but adds code and needs a server that supports ranges. Sharding is more flexible and also needs a manifest of the pieces.
Done when you can
I can show a download progress bar from a stream and
Content-Length.I can explain Range requests and
206, and write a resume that checks its result.I can save files in the Cache API and handle
QuotaExceededError.I can put a version in a cache name and delete old caches.
I can name what I show the user before, during and after a model download.