Cache API & file storage
Store whole responses with the Cache API, inspect browser storage quota, request persistence, and write private origin files with OPFS.
- 01Save responses on purposeUse
cachesand named caches to save pairs: a request and its response. - 02Check your site's spaceRead the rough numbers for used space and quota, and know when the browser may delete data and how to ask it not to.
- 03Write private filesUse OPFS handles to make private folders and files, in browsers that support them.
Saved responses, space, and private files
The browser has several places where a website can save data. This lesson covers three of them:
- The Cache API saves whole answers from the network. You give it a request, like a web address. It keeps the response: the answer the server sent back, with its status, its headers, and its body.
- The Storage Manager,
navigator.storage, tells you about how much space your site uses, and how much it may use. It can also ask the browser not to delete your data on its own. - The Origin Private File System, or OPFS, gives your site its own private folder of files.
Each one belongs to one origin. An origin is the start of a web address: the scheme (like https), the host (like example.com), and the port. Pages from a different origin can't read your data.
These tools help an app load fast and keep working without a network. Documentation sites, drawing tools, recipe apps, music editors, and web apps you install all use them. In this lesson you'll pick a tool by asking two questions: what kind of data is it, and how long must it stay?
You cook a meal once and keep the whole thing in the pantry. Later you take it out, ready to eat, without going to the shop. Each box has a label, so you can find it. Nobody throws out old food for you. You check the shelf and clear it yourself.
- In real life: A ready meal
- In JavaScript: A whole
Response: status, headers, and body - In real life: The label on the box
- In JavaScript: The request or URL you save it under
- In real life: One pantry shelf
- In JavaScript: One named cache, opened with
caches.open() - In real life: You throw out old food yourself
- In JavaScript: Your code deletes old entries
Definition: caches is the browser's list of your site's named caches. Each cache saves pairs: a request and its response. OPFS is a private folder of files for one origin. Quota is the most space the browser expects to let your origin use.
The Cache API
MODELThe global caches object is a CacheStorage: the list of your site's named caches. You can use it on pages and in workers. It only works on a secure page: one loaded over https, or from http://localhost while you build your site.
Call caches.open(name) to get one named cache. Then use its methods:
put(request, response)saves a response you already have.add(url)downloads the URL and saves the response for you.match(request)finds a saved response.keys()lists the saved requests.delete(request)deletes one entry.
const response = new Response("tea", { status: 200 });
const cache = new Map([["/menu", response]]);
console.log(cache.get("/menu").status);This example uses a Map as a pretend cache, so it runs anywhere. Line 1 makes a Response with the body tea and the status 200. Status 200 means "OK". Line 2 saves the response in the map under the key /menu. Line 3 looks up /menu and prints the status: 200.
A real cache works with promises, so you await each method. But it saves the same kind of pair: a request as the key, and a response as the value.
/menu. Line 3 looks up /menu and reads the status, 200.The Cache API is not the browser's own HTTP cache. The HTTP cache is where the browser keeps network answers by itself, following rules the server sends. Read each row of the table as a question, then compare the two answers.
| Question | The Cache API | The browser's HTTP cache |
|---|---|---|
| Who decides what is saved? | Your JavaScript code. It opens named caches and saves entries. | The browser. It follows rules sent with each request and response. |
| What is saved? | The request and response pairs you choose. | Network responses that the browser thinks it can use again. |
| When are old entries deleted? | Never because of age. Your code deletes them. | Headers such as Cache-Control say how long an entry may be used. |
| Who uses it most? | Service workers, and apps that work offline. | Every page. The browser uses it for each request on its own. |
const cache = await caches.open("cf-demo-pantry");await cache.put( "/cf-demo/menu.json", new Response(JSON.stringify(menu), { headers: { "Content-Type": "application/json", "X-Saved-At": savedAt }, }),);await cache.add(location.href);const match = await cache.match("/cf-demo/menu.json?from=button", { ignoreSearch: true });const allKeys = await cache.keys();await cache.delete("/cf-demo/menu.json");This code uses the real Cache API. Line by line:
- Line 1 opens the cache named
cf-demo-pantry. If it doesn't exist yet, the browser makes it. - Lines 2 to 7 save a JSON response with
put, under the address/cf-demo/menu.json. - Line 8 downloads this page with
addand saves it. - Line 9 finds the menu again.
ignoreSearch: truemeans "ignore the part after the?", so?from=buttondoesn't matter. - Line 10 lists every saved request.
- Line 11 deletes the menu entry.
The cache saves the whole Response, headers included. The body is different. A response body is a stream: data that you read once, from start to end. After one read, the body is used up. So if you need to read a response and also save it, make a copy first with response.clone().
Some responses come from another origin that doesn't allow your code to read them. They are called opaque responses. You can save them, but your code can't look inside. Browsers may also count them as much bigger than they really are, so no one can learn their real size.
const response = new Response("tea");
const copy = response.clone();
Promise.all([response.text(), copy.text()]).then((texts) => {
console.log(texts.join(" | "));
});Line 1 makes a response with the body tea. Line 2 makes copy, a clone with its own body. Line 3 reads both bodies with text(). Each text() gives a promise, so Promise.all waits for both. Line 4 joins the two texts and prints tea | tea.
Without line 2, a second text() on the same response would fail, because its body is already used.
copy its own body. Each body can be read once, so both reads give tea.A cache keeps its entries until your code deletes them. So apps put a version in each cache name, like menu-v1 and menu-v2. When a new version arrives, the app deletes the old caches.
const currentCache = "menu-v2";
const cacheNames = ["menu-v1", currentCache, "images-v1"];
const oldNames = cacheNames.filter((name) => name.startsWith("menu-") && name !== currentCache);
console.log(oldNames);Line 1 says the current cache is menu-v2. Line 2 lists three cache names. Line 3 keeps the names that start with menu- and are not the current one. Line 4 prints ["menu-v1"].
A real app would then pass each old name to caches.delete(). The browser never deletes an entry because it is old.
It is great for giving back saved responses. It is bad at searching records, or changing one part of a record. Records you need to search belong in IndexedDB. Files, like drafts, belong in OPFS or IndexedDB.
Three ways to find saved responses:
cache.match()searches one named cache.caches.match()searches all your named caches, and gives the first match.cache.matchAll()gives every match, for when one request matches several saved responses.
Pantry playground
INTERACTIVEThis playground uses the real Cache API, if your browser has caches. Everything goes into one cache, named cf-demo-pantry. Each button does one job:
- Save the menu saves a made-up JSON response with
put. - Save this page downloads this page with
cache.add(location.href)and saves it. - Find the menu uses
matchwithignoreSearch. It finds the menu even with?from=buttonat the end of the address. - List saved requests calls
keys(). - Search all caches calls
caches.match, which looks in every named cache. - Delete the menu deletes the menu entry.
const cache = await caches.open("cf-demo-pantry");await cache.put( "/cf-demo/menu.json", new Response(JSON.stringify(menu), { headers: { "Content-Type": "application/json", "X-Saved-At": savedAt }, }),);await cache.add(location.href);const match = await cache.match("/cf-demo/menu.json?from=button", { ignoreSearch: true });const allKeys = await cache.keys();await cache.delete("/cf-demo/menu.json");- 1
Press a button. Each one uses the real Cache API in this browser.
Everything goes into one cache, cf-demo-pantry, and Reset deletes it. The Cache API needs a secure page: https, or localhost.
Reset deletes the demo cache. Leaving the page deletes it too, so the lesson leaves nothing behind in your browser.
Two ways to use a cache
STEP THROUGHA strategy is a rule for when to use the cache and when to use the network. Service workers use these rules a lot. A service worker is a script that sits between your page and the network. You can use the same rules in normal page code. Here are two:
- Cache-first: look in the cache. If the answer is there, use it and skip the network.
- Stale-while-revalidate: give the saved answer now, even if it is old ("stale"). At the same time, fetch a fresh copy and save it for next time ("revalidate").
async function getMenu(cache, network, mode) { const cached = cache.match("/menu.json"); if (cached && mode === "cache-first") { return { source: "cache", menu: cached.menu }; } if (cached && mode === "stale-while-revalidate") { network.fetch("/menu.json").then((fresh) => { cache.put("/menu.json", fresh); }); return { source: "cache", menu: cached.menu }; } const fresh = await network.fetch("/menu.json"); cache.put("/menu.json", fresh); return { source: "network", menu: fresh.menu };}The function gets a cache, a network, and a mode. Here is what each part does:
- Line 2 looks for
/menu.jsonin the cache. - Lines 4 to 6 are for cache-first. If the menu was found, line 5 returns it. The network is never asked.
- Lines 8 to 13 are for stale-while-revalidate. Line 9 starts a network request, and line 12 returns the saved menu right away, without waiting. When the network answers later, line 10 saves the fresh menu.
- Lines 15 to 17 run only when nothing was found. They fetch the menu, save it, and return it.
The cache and the network here are small pretend objects, so the step-through works in any browser.
Pick a strategy, then press Step to see which lines run.
script
const cached = cache.match("/menu.json"); if (cached && mode === "cache-first") { return { source: "cache", menu: cached.menu }; } if (cached && mode === "stale-while-revalidate") { network.fetch("/menu.json").then((fresh) => { cache.put("/menu.json", fresh); }); return { source: "cache", menu: cached.menu }; } const fresh = await network.fetch("/menu.json"); cache.put("/menu.json", fresh); return { source: "network", menu: fresh.menu };}Pick a strategy and press Step. In both, the cache already holds yesterday's menu: soup and salad. With stale-while-revalidate, the function still returns soup and salad. Then the fresh menu, tacos and tea, is saved for next time.
When to use each one:
- Cache-first suits files with a version in their name, and the app shell: the basic HTML, CSS, and scripts every page needs. The saved copy is almost always right.
- Stale-while-revalidate suits data where speed matters and an old copy is fine for a moment, like a list of news stories.
- Neither suits data that must always be up to date, like a bank balance or a price at checkout. An old value there can hurt the user.
How much space your site gets
INTERACTIVEYour site gets an amount of space, like an apartment. The size depends on the device and the browser. When the building gets crowded, things may be cleared out of apartments nobody has used for a while. Asking for persistent storage is like asking the landlord to keep your things, even when the building is crowded.
- In real life: The size of the apartment
- In JavaScript: Your origin's quota: the most space it may use
- In real life: The things already inside
- In JavaScript: Usage: the space your origin uses now
- In real life: The landlord promises not to clear your things
- In JavaScript: Persistent storage, asked for with
persist() - In real life: Your things get cleared out
- In JavaScript: Eviction: the browser or the user deletes the site's data
navigator.storage has three methods for this:
estimate()gives two numbers that are about right, but not exact:usage, the bytes your origin uses now, andquota, the most it may use. Some browsers also giveusageDetails, a breakdown by kind of storage.persisted()says whether your data is already persistent:trueorfalse.persist()asks for persistent storage. Persistent means the browser won't delete the data on its own when space runs low. Some browsers say yes right away, some ask the user, and some say no.
When the browser deletes a site's data to free space, that is called eviction.
function percentUsed(usage, quota) {
return Math.round((usage / quota) * 100);
}
console.log(percentUsed(25, 100) + "% used");Lines 1 to 3 make a function, percentUsed. It divides usage by quota, multiplies by 100, and rounds. Line 4 calls it with a pretend estimate: 25 used out of 100. It prints 25% used.
Real estimates are in bytes, and they are only about right. Use them as a guide, not as an exact count.
const estimate = await navigator.storage.estimate();const persisted = await navigator.storage.persisted();// Only ask after a user click; some browsers prompt here.const granted = await navigator.storage.persist();Line 1 gets the estimate. Line 2 checks whether the data is already persistent. Line 4 asks for persistence. Only call persist() after the user clicks something, because some browsers show the user a question.
const estimate = await navigator.storage.estimate();const persisted = await navigator.storage.persisted();// Only ask after a user click; some browsers prompt here.const granted = await navigator.storage.persist();- 1
Press Check space to ask the browser how much space this site uses, and how much it may use.
The numbers are only about right, and they can change. When you ask to keep data, the browser may say yes, ask you, or say no.
Persistent storage only makes eviction less likely. It is not a backup. The user can still clear the site's data. In a private window, data may be deleted when the window closes. So your app should be able to rebuild its caches when they are gone.
A private folder of files
INTERACTIVEPicture a folder on the computer that belongs only to your site. Your app saves files there without asking the user where to put each one. Good uses: drafts, pieces of a video, project files, and pictures the app makes.
- In real life: Your site's own folder
- In JavaScript: The OPFS root folder for your origin
- In real life: Folders inside it
- In JavaScript:
getDirectoryHandle(name, { create: true }) - In real life: Text files and picture files
- In JavaScript:
getFileHandle,createWritable,getFile() - In real life: A locked door
- In JavaScript: Only pages from the same origin can open it
Your code reaches files through handles. A handle is an object that points to one folder or one file. navigator.storage.getDirectory() gives the handle of the top folder, called the root. Its type is FileSystemDirectoryHandle.
menu-note.txt into its private cf-demo folder. Other sites can't see that folder, and the user's Files app doesn't show it.In browsers that support it, working with a file takes these steps:
- Make or open a file in a folder.
- Call
createWritable()to get a writable stream: an object you write into. - Write text or bytes into it.
- Close the stream. The file changes only when you close it.
- Read the file with
getFile(), then.text(). - List what is in a folder, and delete things with
removeEntry.
There are also sync access handles, which read and write without promises. They only work in dedicated workers: scripts that run in the background for one page. This page uses a writable stream instead, when your browser has one.
const files = new Map();
files.set("draft.txt", "tea notes");
console.log(files.get("draft.txt"));
files.delete("draft.txt");
console.log(files.has("draft.txt"));This example uses a Map as a pretend folder. Line 1 makes the folder. Line 2 saves a file named draft.txt with the text tea notes. Line 3 reads it and prints tea notes. Line 4 deletes the file. Line 5 checks for it and prints false.
Real OPFS does the same steps with promises. When you write with a writable stream, close() is the step that saves your changes.
const root = await navigator.storage.getDirectory();const demo = await root.getDirectoryHandle("cf-demo", { create: true });const file = await demo.getFileHandle("menu-note.txt", { create: true });const writable = await file.createWritable();await writable.write("Cache pantry inspected at lunch.");await writable.close();const text = await (await file.getFile()).text();for await (const [name, handle] of demo.entries()) { console.log(name, handle.kind);}await root.removeEntry("cf-demo", { recursive: true });Here is the real code, line by line:
- Line 1 gets the root folder.
- Line 2 opens a folder named
cf-demo.{ create: true }makes the folder if it doesn't exist yet. - Line 3 opens the file
menu-note.txtthe same way. - Line 4 gets a writable stream. Line 5 writes text into it.
- Line 6 closes the stream. Now the file holds the text.
- Line 7 reads the file back as text.
- Lines 8 to 10 print each thing in the folder with its kind:
fileordirectory. - Line 11 deletes the whole
cf-demofolder.recursive: truemeans "delete everything inside it too".
const root = await navigator.storage.getDirectory();const demo = await root.getDirectoryHandle("cf-demo", { create: true });const file = await demo.getFileHandle("menu-note.txt", { create: true });const writable = await file.createWritable();await writable.write("Cache pantry inspected at lunch.");await writable.close();const text = await (await file.getFile()).text();for await (const [name, handle] of demo.entries()) { console.log(name, handle.kind);}await root.removeEntry("cf-demo", { recursive: true });- 1
Press Write a file to try OPFS in this browser.
The file goes into a private folder, cf-demo, for this site. Reset deletes the folder, and so does leaving the page.
Which storage should I choose?
SORTTo choose, ask what kind of data you have:
- Is it a whole response from the network?
- Is it a private file?
- Is it a tiny piece of text, like a setting?
- Is it a set of records you need to search?
- Is it a secret, like a login token, that JavaScript should never read?
function chooseStorage(need) { if (need === "offline app shell") return "Cache API"; if (need === "large private file") return "OPFS or IndexedDB"; if (need === "small UI preference") return "localStorage"; if (need === "queryable records") return "IndexedDB"; return "HttpOnly cookie";}Each if on lines 2 to 5 checks for one kind of need and returns a storage name. Line 6 handles every other need. In this small example, that is a login token, so it returns HttpOnly cookie. An HttpOnly cookie is a cookie that the server sets and that JavaScript can't read.
- The files an app needs offline: HTML, CSS, icons, and JSON responses
- A profile picture
Responsefrom your own site, for offline use - A big private draft video or drawing file
- Thousands of records you need to search
- Theme = dark
- Which step of a form this tab is on
- A login token that JavaScript should never read
- A login proof the browser sends with each request to your site
Put each thing an app needs to save under the storage that fits it best.
The table sums up the choices. Read each row as: for this need, use this storage, for this reason.
| What you need to save | A good choice | Why it fits |
|---|---|---|
| The files an app needs offline, or a response to give back later | Cache API | It saves whole responses, headers included. |
| Big drafts, or files like pictures and videos | OPFS or IndexedDB | Both are private, and both are made for bigger app data. |
| A small setting for the page | localStorage | It saves short text under a name. |
| Records you need to search | IndexedDB | It keeps records in stores, with indexes for fast search. |
| A login token | HttpOnly cookie | JavaScript can't read it. |
Where you will use this
REAL APPSPicture a restaurant menu app, with an editor for the restaurant's staff:
- The public app caches the menu JSON and the app shell. Visitors who come back see the menu right away.
- Before saving big picture drafts, the editor checks the space with
estimate(). - Draft notes, and pictures the app makes, are saved in OPFS, in one folder per project.
- The user's theme choice is a tiny value in
localStorage. - The login token is in an HttpOnly cookie, where scripts can't read it.
A service worker can answer requests from the Cache API while the page is offline. The page itself can keep app data in IndexedDB or OPFS. The Service workers & offline apps lesson covers the service worker part. This lesson covers only the storage.
Good code also cleans up after itself:
- Put a version in each cache name, and delete old versions.
- Set a size limit for big OPFS projects.
- Rebuild a cache if it disappears.
- Don't let browser storage be the only copy of data the user can't lose.
Plan for saved data going missing:
- If the cache has no match, fetch the data again.
- If an OPFS file is missing, make a new draft, or offer another way to go on.
- If
persist()says no, save less of the data you don't need. Don't stop the whole app. - If losing the local copy would hurt the user, keep the main copy on the server.
Common misconceptions
WATCH OUT- "The Cache API is the HTTP cache." They are two separate things. Your code controls the named caches. The browser controls the HTTP cache.
- "Cache API entries are deleted when they get old." They are not. Put a version in each cache name, and delete old caches yourself.
- "I can read a saved response's body as often as I like." You can find a saved response again and again. But each
Responseyou get has a body you can read only once. Clone it before reading it twice. - "Quota is space I'm promised." It is only an estimate, and it can change. Data that is not persistent may be deleted.
- "
persist()always asks the user." Browsers differ. It may say yes right away, ask the user, or say no. - "OPFS files show up in the user's file browser." They don't. They are private to the origin, and they are deleted when the user clears the site's data.
- "Closing a writable stream is optional." The file doesn't change until you close the stream. If writing fails, call
abort()instead, so no half-written file is saved. - "A quota estimate tells me exactly how many bytes I can save." It doesn't. The browser may squeeze data smaller, store repeated data once, pad some sizes for privacy, or lower the limit when the disk fills up.
Practice exercises
5 EXERCISESThis map is a pretend cache. What does line 4 print?
const response = new Response("hello", { headers: { "X-Saved-At": "noon" } });
const cache = new Map();
cache.set("/menu", response.clone());
console.log(cache.get("/menu").headers.get("X-Saved-At"));const response = new Response("hello", { headers: { "X-Saved-At": "noon" } });
const cache = new Map();
cache.set("/menu", response.clone());
console.log(cache.get("/menu").headers.get("X-Saved-At"));Line 3 saves a clone of the response in the map. The clone keeps the headers, so line 4 prints noon.
What two lines does this code print?
const response = new Response("one-use body");
const copy = response.clone();
response.text().then((text) => console.log(text));
copy.text().then((text) => console.log(text));const response = new Response("one-use body");
const copy = response.clone();
response.text().then((text) => console.log(text));
copy.text().then((text) => console.log(text));response and copy each have their own body, with the same text. So both lines print one-use body.
A teammate says, "The Cache API deletes old entries after a day." Who really has to delete them?
Your app's code. When the disk is full, the browser may delete a whole site's data. But it never deletes Cache API entries because they are old.
Which method tells you about how much space your origin uses, and how much it may use?
Call navigator.storage.estimate(). It gives usage and quota, and both are only about right.
Which method opens the OPFS root folder?
Call navigator.storage.getDirectory(). It gives the root folder's handle. Then you can make folders and files inside it.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does a named cache save?
Choose an answer to see the explanation.
Question 2 of 8What does this print?
Read the code, then predictconst response = new Response("hello", { headers: { "X-Saved-At": "noon" } }); console.log(response.headers.get("X-Saved-At"));Choose an answer to see the explanation.
Question 3 of 8Which sentence about old Cache API entries is true?
Choose an answer to see the explanation.
Question 4 of 8What does this check print when it runs in Node, not in a browser?
Read the code, then predictconsole.log(typeof caches === "undefined" ? "no Cache API" : "Cache API available");Choose an answer to see the explanation.
Question 5 of 8What does
navigator.storage.estimate()give you?Choose an answer to see the explanation.
Question 6 of 8Which method opens the OPFS root folder?
Choose an answer to see the explanation.
Question 7 of 8Why clone a response before you save it and also read it?
Choose an answer to see the explanation.
Question 8 of 8Where should a secret login token go?
Choose an answer to see the explanation.
Key takeaways
- The Cache API saves pairs: a
Requestand itsResponse. It never deletes them because they are old. - A response body can be read once. Clone the response when two readers need the body.
navigator.storage.estimate()gives rough numbers for usage and quota.persist()asks the browser not to delete your data on its own.- OPFS gives your origin a private folder of files, reached through handles. Check that the browser supports it, and clean up after yourself.
- Pick storage by the kind of data: responses, files, small settings, records, or secrets.
Final definition: The Cache API saves responses to give back later. The Storage Manager tells you about your origin's space. OPFS saves private files for apps that work with files.
Up next: Observers. They tell your code when an element comes into view, when the page changes, or when an element changes size.