completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

The browser's caches

Memory cache, HTTP cache, service worker cache, code cache, and back/forward cache: which one served your file? A shop, a blog and a CDN, recorded in Chrome over a real round trip, with DevTools naming the source of every file, a trace of V8's code cache, and trips back and forth.

By the end, you can
  • 01
    Name the cache that served a fileRead DevTools and Resource Timing, and tell the memory cache, the HTTP cache and a service worker apart.
  • 02
    Say when each cache missesA new tab, another site, a stopped worker, a private window, an unload handler: say what each one costs.
  • 03
    Keep the fast caches workingKeep big scripts stable, leave out unload handlers, and use a service worker for what the HTTP cache can't do.

Which cache served your file?

Asha opens the Mint tea shop on her laptop. Her browser downloads the page and three files: the stylesheet main.css, the hero photo, and a small script, library.js. The script comes from a CDN, a server that hosts the same files for many sites.

Then Asha clicks through to the shop's All teas page, in the same tab. A little later, she opens All teas again, this time in a new tab. Both pages use the same stylesheet. So how long did main.css take each time? The course timed it:

The same stylesheet, three visitsPop out in the code editor (opens in a new tab)JavaScript
// main.css on three visits: where DevTools said it came from,// and how long it took, in ms (the median of 5 runs).const firstVisit = { from: "the network", ms: 105.7 };const sameTab = { from: "the memory cache", ms: 0 };const newTab = { from: "the disk cache", ms: 0.6 }; console.log(`First visit: main.css came from ${firstVisit.from} in ${firstVisit.ms} ms.`);console.log(`Next page, same tab: it came from ${sameTab.from} in ${sameTab.ms} ms.`);console.log(`Next page, new tab: it came from ${newTab.from} in ${newTab.ms} ms.`);
  • Lines 1 and 2 are comments. The course made each visit 5 times and kept the middle time, the median. Times are in milliseconds (ms), thousandths of a second.
  • Lines 3 to 5 hold what was recorded: where Chrome's DevTools said the file came from, and how long it took.
  • Lines 7 to 9 print First visit: main.css came from the network in 105.7 ms., Next page, same tab: it came from the memory cache in 0 ms. and Next page, new tab: it came from the disk cache in 0.6 ms.

The first visit had to fetch the file: a round trip to the shop's server and back. The other two didn't fetch it at all. But DevTools names two different places, the memory cache and the disk cache. And one of them took no time at all. Why two places? And are there more?

There are. A cache is a store of things the browser fetched or built before, kept so it doesn't have to do the work again. Chrome has several. For each file a page asks for, it checks them in a fixed order, nearest first:

Where Chrome looks for a file a page asks for, nearest first. First the memory cache, in the page's process. Then a service worker, your own code. Then the HTTP cache, with one part per site. Last, the network: the server. Two more caches hold other things. The code cache holds compiled scripts, and the back/forward cache holds whole pages for the Back button.For each file a page asks for:memory cachethe page's processservice workeryour own codeHTTP cacheone part per sitenetworkthe serverAnd two more:code cachecompiled scriptsback/forward cachewhole pages, for Back
Shaded: a cache. For a file, Chrome tries the top row from left to right and stops at the first one that answers.
  • The memory cache holds files that the page's own process has already loaded.
  • A service worker is a script your site can install. It can answer from its own store, called Cache Storage.
  • The HTTP cache holds responses for as long as the server's Cache-Control header allows. HTTP caching in depth was all about this one.
  • The network comes last: the server itself.

Two more caches hold things other than files. The code cache keeps scripts the browser has already compiled. The back/forward cache keeps whole pages, so the Back button can show them at once.

Real-life analogyYour desk, your drawer, and the filing room down the hall

The papers you're working with are right on your desk: you just pick one up. When you start a new task, you clear the desk, but older papers stay in your drawer. Getting one out takes a moment. Anything else means a walk to the filing room down the hall, and back.

In real life: A paper already on your desk
In JavaScript: A file in the memory cache: 0 ms
In real life: Clearing your desk for a new task
In JavaScript: Opening a new tab: its memory cache starts empty
In real life: Your desk drawer
In JavaScript: The HTTP cache: a moment to open, and still there for the next task
In real life: A walk to the filing room and back
In JavaScript: The network: a round trip to the server
The main idea

Several caches can answer before the network, and each one is empty in different situations. To speed up a repeat visit, find out which cache served each file, and why the nearer ones missed. DevTools tells you. In the Network panel, the Size column says (memory cache) or (disk cache) instead of a number of bytes, as The Network panel for performance showed.

How the course timed it

  • The shop lives at shop.test, a tea blog at blog.test, and the CDN at cdn.test. All three ran on one computer, over HTTPS and HTTP/2.
  • The stylesheet, the hero and library.js came with Cache-Control: max-age=3600: fresh for an hour. Each page came with no-cache: keep it, but check it before using it again.
  • A small program, a delay proxy, held every message 50 ms on the way there and 50 ms on the way back. So each round trip took 100 ms, like a server far away.
  • Chrome 156 ran each experiment 5 times. Each run used a fresh private window, so every cache started empty. One code cache experiment was the exception, and its section explains why.
  • Before each new tab, the course also closed every open connection.

Every experiment in this lesson is in this playground. Pick one, then a step. Each row is one file: where it came from, its size in bytes, and its time. The code cache and Back experiments show rows of their own, which their sections explain.

Playground: which cache was it?
What happened
  1. main.css: the memory cache0 bytes, 0 ms
  2. hero.jpg: the memory cache0 bytes, 0 ms
  3. library.js: the memory cache0 bytes, 0 ms
Try it yourself
Memory or disk, next page, same tab: first paint at 128 ms

The servers got: shop.test/teas. Each source is what DevTools reported, and it was the same in all 5 runs. Each time is the median of the 5.

Recorded in Chrome 156 on Apple M1 Pro, over HTTP/2, through a proxy that makes each round trip take 100 ms.

The memory cache and the HTTP cache

Back to Asha's second and third visits. On both, the shop's server got just one request: the All teas page itself. Nobody asked it for main.css, so both caches saved the whole round trip. The difference is where each one keeps its copy.

The memory cache lives with the page

Chrome builds and draws a site's pages in a separate program, called a renderer process. Processes and threads showed how Chrome splits itself up this way. The memory cache lives inside that process. Chromium's design notes call it "Blink's in-memory cache, which lives in the renderer process". Blink is the part of Chrome that builds pages.

Asha's next page, in the same tab, got its stylesheet from the memory cache. So it must have been drawn by the same process, which still held the stylesheet from the first page. No request left the page, which is why it took 0 ms.

The new tab was a fresh start. Chromium's notes say pages in unrelated tabs "can safely run in separate processes", and a new process starts with an empty memory cache. In all 5 runs, the new tab's memory cache didn't have main.css. It's the cleared desk from the story above.

The HTTP cache is shared by every tab

So the new tab looked in the next place, the HTTP cache. This one doesn't live with any page. Chromium's notes place it in Chrome's network code, with "an instance of an HttpCache for every profile". A profile is one person's Chrome, with its own history, settings and caches. So every tab in a profile shares one HTTP cache.

Reading it took 0.6 ms. That's a little longer than 0 ms. The page sends its request out to Chrome's network code, and waits for the copy to come back. But the request goes no further: nothing travels over the network. Next to the first visit's 105.7 ms, it's almost free.

The name "disk cache" comes from where Chrome normally keeps the HTTP cache: on disk. Chromium's notes describe it "serving requests either from disk or from the network".

Why it matters: the memory cache helps while a visitor clicks around in one tab. A visitor who comes back tomorrow, or opens another tab, relies on the HTTP cache instead. So the Cache-Control rules you chose in A caching strategy still decide most repeat visits.

What your page's own code can see

DevTools can tell the two caches apart. Can your page's JavaScript? It can read Resource Timing, the list of every file a page loaded, from Navigation Timing and Resource Timing. Here is what it said about main.css:

What the page's own code sawPop out in the code editor (opens in a new tab)JavaScript
// What Resource Timing said about main.css on each visit (the same in all 5 runs).const firstVisit = { transferSize: 453, deliveryType: "" };const sameTab = { transferSize: 0, deliveryType: "cache" };const newTab = { transferSize: 0, deliveryType: "cache" }; const from = (entry) => (entry.transferSize === 0 ? "a cache" : `the network, ${entry.transferSize} bytes`);console.log(`First visit: ${from(firstVisit)}.`);console.log(`Same tab: ${from(sameTab)}. New tab: ${from(newTab)}.`);
  • Line 1 is a comment. transferSize is how many bytes came over the network. deliveryType says how the file was delivered.
  • Lines 2 to 4 hold the recorded values. Lines 3 and 4 are exactly the same.
  • Line 6 is a small function. A transferSize of 0 means a cache, and anything else means the network.
  • Lines 7 and 8 print First visit: the network, 453 bytes. and Same tab: a cache. New tab: a cache.

MDN says transferSize is 0 "if the resource was retrieved from a local cache without contacting the server." It doesn't say which cache. Both visits also got the same deliveryType, "cache". So your own analytics can count cache hits, but can't tell the memory cache from the HTTP cache. For that, open DevTools.

Cache partitioning by site

Asha also reads a tea blog, at blog.test. It's a different site, but it loads the very same script as the shop: https://cdn.test/library.js. The shop's visit has already put that script in her HTTP cache, fresh for an hour. So does the blog get it for free?

The course loaded the shop, then the blog, then the shop again, each in a new tab:

One CDN script, two sitesPop out in the code editor (opens in a new tab)JavaScript
// Where library.js came from on each visit, as DevTools said (the same in all 5 runs).const shop = "the network";const blog = "the network";const shopAgain = "the disk cache"; console.log(`The shop: library.js came from ${shop}.`);console.log(`The blog, another site: it came from ${blog} again.`);console.log(`The shop again: it came from ${shopAgain}.`);
  • Line 1 is a comment. Each value is where DevTools said library.js came from, the same in all 5 runs.
  • Lines 2 to 4 hold the three visits.
  • Lines 6 to 8 print The shop: library.js came from the network., The blog, another site: it came from the network again. and The shop again: it came from the disk cache.

The blog didn't get it for free. The CDN's server got a second request for library.js. Yet the shop's next visit found its copy in the HTTP cache. The copy was still there; the blog just wasn't allowed to use it.

Real-life analogyEach school keeps its own lost-property box

Every school in town keeps its own lost-property box. Asha leaves her water bottle at her school, so it goes in that school's box. At another school, the box doesn't have it, even though it's the same bottle. If the whole town shared one box, anyone could look through it and see where Asha had been.

In real life: A school
In JavaScript: A top-level site, like shop.test or blog.test
In real life: The school's lost-property box
In JavaScript: That site's own part of the HTTP cache
In real life: Asha's bottle, left at one school
In JavaScript: library.js, stored while she was on the shop
In real life: One box for the whole town
In JavaScript: One shared cache, which shows where you've been

This is called cache partitioning. Every cache files each copy under a label, called its key. Up to Chrome 85, Chrome's key was just the file's URL. From Chrome 86, Chrome's announcement says, the key also holds "the top-level site and the current-frame site."

  • The top-level site is the site in the address bar.
  • The current-frame site is the site of the frame that asked for the file. It's the same as the top-level site, unless the request came from inside an iframe.

Here are the two keys for library.js:

The same URL, two keysPop out in the code editor (opens in a new tab)JavaScript
// Chrome's key for a stored file: the top-level site, the frame's site, and the file's URL.const keyOnTheShop = ["https://shop.test", "https://shop.test", "https://cdn.test/library.js"];const keyOnTheBlog = ["https://blog.test", "https://blog.test", "https://cdn.test/library.js"]; const same = keyOnTheShop.join(" ") === keyOnTheBlog.join(" ");console.log(same ? "Same key: the blog can use the shop's copy." : "Different keys: the blog needs its own copy.");
  • Line 2 is the key when the shop asks. The shop is both the top-level site and the frame's site.
  • Line 3 is the key when the blog asks. Only the URL is the same.
  • Line 5 joins each key into one string and compares the two.
  • Line 6 prints Different keys: the blog needs its own copy.

Why would Chrome give up free cache hits? Because one shared cache gives away your history. Chrome's post explains that "the time a website takes to respond to HTTP requests can reveal that the browser has accessed the same resource in the past". A page could time a file that only one other site uses. If it loads at once, you've been to that site. With partitioning, a page can only find copies stored for its own site.

Chrome counts a site as the scheme plus the main domain name. So a page on www.shop.test uses the same part as one on shop.test. In Chrome's words, "subdomains and port numbers are ignored."

Why it matters: a popular CDN no longer means your visitors already have the file. Each site pays for its own copy. So hosting a library on your own site loses no cache hits. Connections in practice, later in the course, shows that it saves a connection too.

Service worker caches

Now the shop wants its page to keep working when Asha's train goes into a tunnel. The HTTP cache can't promise that. Chrome decides what it keeps. web.dev warns: "There is no guarantee that the content of your origin/domain stays in the HTTP cache for a long time." So the shop adds a service worker.

A service worker is a script the browser runs apart from the page. Once it controls a page, the page's requests go to it before they reach the HTTP cache. It can answer a request itself, from its own store, Cache Storage. Service workers & offline apps in the JavaScript course builds one, and Cache API & file storage covers Cache Storage.

Real-life analogyA cook's own pantry, and the shop next door

A café's cook keeps her own pantry. She decides what goes in, and nothing in it goes off by a date. Next door is a shop that stocks its shelves by the supplier's dates, not hers. When an order comes in, the cook checks her pantry first, and sends out to the shop only if it's empty. But if she has gone home, someone has to call her back in first.

In real life: The cook
In JavaScript: The service worker
In real life: Her own pantry
In JavaScript: Cache Storage, which only your code fills
In real life: The shop next door, stocked by the supplier's dates
In JavaScript: The HTTP cache, kept by the server's Cache-Control
In real life: Calling the cook back in first
In JavaScript: Starting a stopped worker before it can answer

Here is the shop's worker. It keeps two files, the stylesheet and the hero:

The shop's service workerJavaScript
const FILES = ["/main.css", "/hero.jpg"]; addEventListener("install", (event) => event.waitUntil(caches.open("shop-v1").then((cache) => cache.addAll(FILES))));addEventListener("activate", (event) => event.waitUntil(clients.claim()));addEventListener("fetch", (event) => {  if (!FILES.includes(new URL(event.request.url).pathname)) return;  event.respondWith(caches.match(event.request).then((cached) => cached ?? fetch(event.request)));}); 
  • Line 1 lists the two files.
  • Line 3 runs once, when the worker installs. It opens a cache called shop-v1 in Cache Storage. Then cache.addAll() downloads both files into it.
  • Line 4 runs when the worker becomes active. clients.claim() makes it take charge of the open page at once, instead of from the next visit.
  • Lines 5 to 8 run for every request the page makes. Line 6 lets any other file go on as usual.
  • Line 7 answers with the stored copy. If there isn't one, it fetches the file instead.

Line 7 is the one that matters. The worker's cache doesn't answer by itself. web.dev: "You need to create a fetch event handler in your service worker and intercept network requests". Without lines 5 to 8, Cache Storage would hold both files, and nothing would ever use them.

The first visit downloads the files twice

The course sent both files with Cache-Control: no-store, so only the worker could keep them, not the HTTP cache. Here is everything the shop's server got on the first visit:

The first visit, as the server saw itPop out in the code editor (opens in a new tab)JavaScript
// Every request the shop's server got on the first visit with the service worker (the same in all 5 runs).const served = ["/", "/main.css", "/hero.jpg", "/sw.js", "/main.css", "/hero.jpg"]; const times = served.filter((path) => path === "/main.css").length;console.log(`The server sent main.css ${times} times.`);
  • Line 2 is the server's log. First come the page, the stylesheet and the hero. Next is the worker's own script, sw.js. Then the stylesheet and the hero come again.
  • Line 4 counts the requests for the stylesheet.
  • Line 5 prints The server sent main.css 2 times.

The first copy went to the page. The second was line 3 of the worker, filling Cache Storage. With no-store, the HTTP cache had no copy to hand the worker, so the file came over the network again. Why it matters: filling a worker's cache can cost a second download on the first visit. The next visit pays it back.

Coming back: the worker or the HTTP cache?

On Asha's repeat visit, in a new tab, both files came from the worker, and the server got only the page. Was that faster than the HTTP cache? The course made the same repeat visit without a worker, with the files kept by max-age=3600 instead:

HTTP cache or service workerPop out in the code editor (opens in a new tab)JavaScript
// main.css on a repeat visit, in a new tab (the median of 5 runs, in ms).const httpCache = { from: "the disk cache", ms: 1.1 };const worker = { from: "the service worker", ms: 2.5 }; console.log(`Without a worker: main.css came from ${httpCache.from} in ${httpCache.ms} ms.`);console.log(`With a running worker: it came from ${worker.from} in ${worker.ms} ms.`);
  • Lines 2 and 3 hold the two repeat visits: without a worker, and with one that was already running.
  • Lines 5 and 6 print Without a worker: main.css came from the disk cache in 1.1 ms. and With a running worker: it came from the service worker in 2.5 ms.

The worker was a little slower. To answer, Chrome had to hand the request to the worker, run its fetch handler, and look in Cache Storage. Both times are tiny next to a 100 ms round trip.

A stopped worker has to start first

A service worker doesn't run all the time. MDN says it "can and will be terminated when idle to conserve memory and processor power." The next request starts it again. The course stopped the worker before Asha's repeat visit, so it had to start before it could answer:

A running worker and a stopped onePop out in the code editor (opens in a new tab)JavaScript
// main.css from the service worker on a repeat visit (the median of 5 runs, in ms).const running = 2.5;const stopped = 13.4; console.log(`Running worker: ${running} ms. Stopped worker: ${stopped} ms.`);console.log(`The stopped worker took ${Math.round(stopped - running)} ms longer.`);
  • Lines 2 and 3 hold the stylesheet's time from a running worker and from a stopped one.
  • Line 5 prints Running worker: 2.5 ms. Stopped worker: 13.4 ms.
  • Line 6 subtracts and rounds. It prints The stopped worker took 11 ms longer.

That's the cook being called back in. The server still got no request for the files: they came from Cache Storage as before. So the extra time most likely went on starting the worker. DevTools' Timing tab shows that start as a phase of its own.

Here are all five ways the stylesheet arrived, side by side:

The stylesheet: where it came from, as DevTools said, and its median time of 5 runs. First visit: the network, 105.7 ms. Same tab, next page: the memory cache, 0 ms. New tab: the disk cache, 0.6 ms. Service worker, running: the service worker, 2.5 ms. Service worker, stopped: the service worker, 13.4 ms.First visit105.7 ms, networkSame tab, next page0 ms, memoryNew tab0.6 ms, diskService worker, running2.5 ms, service workerService worker, stopped13.4 ms, service worker
The stylesheet, five ways. Shaded: it came from a cache. Even the slowest cache, a stopped worker, took under 14 ms; the network took over 100.

Why it matters: use a service worker for what the HTTP cache can't do, not for speed. It can answer with no network at all. And its files don't go stale by a date: MDN notes that items in Cache Storage "don't expire unless deleted."

The code cache

Every cache so far saved a download. The code cache saves work that comes after the download. Before a script can run, the browser's JavaScript engine has to compile it. Compiling turns the script's text into bytecode: simple instructions the engine can run. Chrome's engine is called V8.

Real-life analogyNotes in the margin of a recipe

You're cooking from a recipe in a language you're still learning. The first time, you translate as you go. The second time, you translate again, and this time you write the translation in the margin. From the third time on, you just read the margin. When a new edition of the recipe comes out, its margins are clean.

In real life: Translating the recipe as you cook
In JavaScript: V8 compiling the script
In real life: Writing the translation in the margin
In JavaScript: Making the code cache, on the second load
In real life: Reading the margin instead
In JavaScript: Using the code cache, and skipping compiling
In real life: A new edition, with clean margins
In JavaScript: A changed script or a new URL: the code cache starts over

V8's post on code caching describes the same three steps. It calls them runs:

  • A cold run: "Chrome downloads it and gives it to V8 to compile."
  • A warm run: Chrome compiles it again. This time, "the compiled code is serialized": turned into bytes that can be stored, and kept with the script.
  • A hot run: "V8 deserializes the metadata and can skip compilation." The metadata is that stored compiled code.

The course built a big script to test this: 6,000 small functions, 591 KB in all. The script calls every function once, so V8 has to compile each one. Chrome loaded it three times in a row, in a normal window, and the course traced each load. A trace is a recording of everything Chrome did, the kind Reading a performance trace taught you to read.

Cold, warm, hotPop out in the code editor (opens in a new tab)JavaScript
// The big script, loaded three times in a row. From a trace of each load: how long V8 spent// compiling and running it (the median of 5 runs, in ms), and the code cache it made or used.const loads = [  { load: "first", ms: 36.3, codeCache: "none" },  { load: "second", ms: 44.9, codeCache: "made, 1,256,400 bytes" },  { load: "third", ms: 6.8, codeCache: "used, 1,256,400 bytes" },]; for (const { load, ms, codeCache } of loads) console.log(`The ${load} load took ${ms} ms. Code cache: ${codeCache}.`);
  • Lines 1 and 2 are comments. Each time is how long V8 spent compiling and running the script, read from the trace.
  • Lines 4 to 6 hold the three loads, with the code cache each one made or used.
  • Line 9 prints one line per load: The first load took 36.3 ms. Code cache: none., The second load took 44.9 ms. Code cache: made, 1,256,400 bytes. and The third load took 6.8 ms. Code cache: used, 1,256,400 bytes.

The third load took 6.8 ms instead of 36.3: about a fifth of the time. The trace shows where the saving came from. Compiling took 28.6 ms on the first load, and 0.1 ms on the third. V8 read the compiled code instead of making it.

The second load was the slowest of the three. It compiled as much as the first, 30.2 ms against 28.6, and it made the code cache as well. So the code cache costs one extra load before it helps.

The big script's evaluation, median of 5 runs. Normal, load 1: 36.3 ms. Normal, load 2: 44.9 ms, made the code cache. Normal, load 3: 6.8 ms, used the code cache. Private, load 1: 34 ms. Private, load 2: 37.3 ms. Private, load 3: 35.8 ms.Normal, load 136.3 msNormal, load 244.9 ms, madeNormal, load 36.8 msPrivate, load 134 msPrivate, load 237.3 msPrivate, load 335.8 ms
Shaded: V8 used the code cache and skipped compiling. In the private window, Chrome never made one, so every load compiled.

A private window keeps no code cache

The picture's last three bars come from the same three loads in a private window. There, the third load was no faster than the first. Chrome never made a code cache at all. Chromium's source code says why: "For Incognito mode, we should not persist anything on the disk so we do not create a code cache." Incognito is Chrome's name for a private window. That's why the first code cache experiment, unlike all the others, used a normal window.

Why it matters: the code cache rewards scripts that don't change. V8's post warns that "whenever you ship new code, that code is not yet cached". Changing a script's URL, "including any query parameters!", starts it cold too. So keep big, stable code, like a library, in its own file, apart from code that changes every week. V8 suggests exactly that: "split out the stable library code into a separate script".

For how V8 starts up in general, see Startup: snapshots & code caching in the JavaScript course.

The back/forward cache

Asha clicks a link away from the shop. Then she presses Back. In The web-vitals library, she came back for a forgotten scarf and found her table just as she left it. That was this cache at work.

The back/forward cache, or bfcache, keeps the whole page when you leave. Not just its files: the page itself, with its JavaScript still in memory. web.dev calls it "a snapshot of the entire page in memory, including the JavaScript heap". The JavaScript heap is where a page's objects and variables live. The HTTP cache, by contrast, "contains only the responses for previously made requests."

Real-life analogyPausing a film, or starting it again

You're watching a film when the doorbell rings. You press pause and answer the door. When you come back, you press play, and the film carries on from the same frame. If the TV had been switched off instead, you'd start the film again from the beginning, opening titles and all.

In real life: Pressing pause as you get up
In JavaScript: Leaving the page: Chrome keeps it, with its JavaScript paused
In real life: Pressing play when you come back
In JavaScript: Back: the same page carries on where it stopped
In real life: Starting the film from the beginning
In JavaScript: Loading the page again: every file and every script, from the start

web.dev says the same: the browser will "postpone destruction and pause JS execution", then unpause the page when the user comes back.

A page can tell which happened. The pageshow event fires when the page first loads, and again each time it's restored from the bfcache. Its persisted property is true "if the page was restored from bfcache and false otherwise."

Restored, or loaded from the start?JavaScript
addEventListener("pageshow", (event) => {  if (event.persisted) console.log("Restored from the back/forward cache.");  else console.log("Loaded from the start.");});
  • Line 1 listens for pageshow.
  • Line 2 prints Restored from the back/forward cache. when the page was unpaused.
  • Line 3 prints Loaded from the start. on any other showing, including the first load.

The course's shop page did the same, and timed the trip back too: from the moment history.back() was called to pageshow. It tried two versions of the page, both sent with no-cache. One had an unload handler: a function that runs on the unload event. The navigation timeline called that event the old page's last chance to run code. The handler was empty: addEventListener("unload", () => {});

Away, then backPop out in the code editor (opens in a new tab)JavaScript
// Away, then back. Was the page restored (the same in all 5 runs), and how long did// the trip back take, from history.back() to pageshow (the median of 5 runs, in ms)?const plainPage = { restored: true, ms: 6 };const unloadPage = { restored: false, ms: 16 }; const trip = (page) => `${page.restored ? "restored" : "loaded again"} in ${page.ms} ms`;console.log(`The page: ${trip(plainPage)}.`);console.log(`The page with an unload handler: ${trip(unloadPage)}.`);
  • Lines 1 and 2 are comments. Whether a page was restored was the same in all 5 runs.
  • Line 3 is the plain page. Line 4 is the page with the unload handler.
  • Line 6 turns one page's result into words.
  • Lines 7 and 8 print The page: restored in 6 ms. and The page with an unload handler: loaded again in 16 ms.

The plain page came back in 6 ms: Chrome just unpaused it. The page with the unload handler wasn't restored, in any run. Chrome gave its reason as UnloadHandlerExistsInMainFrame: the page had an unload handler.

Why would an empty handler matter? web.dev explains that many pages assume "a page won't continue to exist after the unload event has fired." A page in the bfcache does continue to exist. So a browser must either skip the handler, or keep the page out. Firefox on desktop keeps it out, web.dev says, and in the course's runs, so did Chrome. web.dev's advice is blunt: "never use the unload event. Ever!"

The page that wasn't restored still came back in 16 ms, without a single request to the server. Chrome loaded it again from the HTTP cache, without even checking the no-cache page. The HTTP caching standard allows that for the Back button: "a history mechanism can display a previous representation even if it has expired". Its scripts still ran again from the start.

A page sent with no-store

no-store tells every cache never to keep a copy. web.dev says it's used for sensitive pages, "such as pages behind a login." It also says browsers have kept such pages out of the bfcache, and that "there is work underway to change this behavior for Chrome in a privacy-preserving manner." The course made the same trip with a no-store page:

The same trip, with no-storePop out in the code editor (opens in a new tab)JavaScript
// The same trip back, for a page sent with no-store.const noStorePage = { restored: true, ms: 6 };const noStoreUnloadPage = { restored: false, ms: 119 }; const trip = (page) => `${page.restored ? "restored" : "loaded again"} in ${page.ms} ms`;console.log(`The no-store page: ${trip(noStorePage)}.`);console.log(`The no-store page with an unload handler: ${trip(noStoreUnloadPage)}.`);
  • Lines 2 and 3 hold the no-store page, without and with an unload handler.
  • Lines 6 and 7 print The no-store page: restored in 6 ms. and The no-store page with an unload handler: loaded again in 119 ms.

Chrome 156 restored the no-store page, just like the plain one. With an unload handler, the page had to load again. This time the HTTP cache had no copy, because of no-store. So the page came from the network: 119 ms, more than a whole round trip. Here are all four trips back:

From history.back() to pageshow, median of 5 runs. no-cache page: restored in 6 ms. no-store page: restored in 6 ms. unload handler: loaded again in 16 ms. no-store, unload handler: loaded again in 119 ms.no-cache page6 ms, restoredno-store page6 ms, restoredunload handler16 ms, loaded againno-store, unload handler119 ms, loaded again
Shaded: restored from the back/forward cache. The two pages with an unload handler loaded again instead: one from the HTTP cache, the no-store one from the network.

Why it matters: one empty unload handler turned a 6 ms trip back into a full load from the network. Leave the unload event out of your pages.

Asking why a page wasn't restored

A page can ask why it wasn't restored. Its navigation entry has a notRestoredReasons property, with a list of reasons. Chrome's docs warn that it doesn't always give the real reason. To see what the course's page was told, pick Back, page with an unload handler in the playground.

Which cache was it?

Here are the five caches side by side. Read each row across: what the cache keeps, who decides what goes in, and what got in its way in the course's runs.

The five caches, side by side
CacheWhat does it keep?Who fills it?What got in the way?
Memory cacheFiles the page's process has already loaded.Chrome does, as the page loads.A new tab started without it.
HTTP cacheResponses, for as long as their Cache-Control allows.Chrome does, following the server's headers.Another site has its own part.
Service worker cacheWhatever your worker put in Cache Storage.Your own code does, with cache.addAll().A stopped worker had to start first.
Code cacheV8's compiled code for a script.Chrome and V8 do, on the script's second load.It needs two loads first, and a private window never gets one.
Back/forward cacheThe whole page, paused, with its JavaScript.Chrome does, as you leave the page.An unload handler kept the page out.

Now sort each case by the cache that served it:

Which cache was it?
  • The stylesheet, used again by the next page in the same tab
  • The hero photo, shown again on the next page in the same tab
  • A stylesheet with max-age, in a new tab on the same site
  • A CDN script, back on the site that loaded it a few minutes before
  • A file the worker stored when it installed
  • The shop's page, answered by a fetch handler while Asha is offline
  • A whole page with its JavaScript state, after Back
  • A page whose timers carry on from where they paused, after Back
Try it yourself
0 of 8 correct

Sort each case by the cache that served it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A popular CDN shares its cache between sites." No. Chrome keeps one part of the HTTP cache per site. The blog downloaded the CDN's script again, though the shop had it.
  • "(memory cache) and (disk cache) are two names for the same cache." No. The memory cache lives in the page's process. The disk cache is DevTools' name for the HTTP cache, which every tab shares.
  • "A service worker is faster than the HTTP cache." No. A running worker took 2.5 ms against the HTTP cache's 1.1, and a stopped one took 13.4. Use a worker for offline pages and control, not speed.
  • "The code cache helps the second load." No. The second load makes it, and it was the slowest of the three. The third load uses it.
  • "A page that can change can't be restored on Back." No. The no-cache page came back from the bfcache in 6 ms, exactly as Asha left it.
  • "An empty unload handler is harmless." No. It kept the page out of the bfcache in every run, though it did nothing.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upA private window

Here are the same three loads, in a private window. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// The big script, loaded three times in a private window. From a trace of each load: how long V8 spent
// compiling and running it (the median of 5 runs, in ms), and the code cache it made or used.
const loads = [
  { load: "first", ms: 34, codeCache: "none" },
  { load: "second", ms: 37.3, codeCache: "none" },
  { load: "third", ms: 35.8, codeCache: "none" },
];

for (const { load, ms, codeCache } of loads) console.log(`The ${load} load took ${ms} ms. Code cache: ${codeCache}.`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe hero, from a stopped worker

    This time it's the hero photo, from the same two workers. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // hero.jpg from the service worker on a repeat visit (the median of 5 runs, in ms).
    const running = 2;
    const stopped = 14.9;
    
    console.log(`Running worker: ${running} ms. Stopped worker: ${stopped} ms.`);
    console.log(`The stopped worker took ${Math.round(stopped - running)} ms longer.`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThe blog's copy

      On the blog, how long did the CDN's library.js take?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWhat the page was told

        For the page with an unload handler, what did notRestoredReasons on the page say?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeThe start-up phase

          DevTools' Timing tab can show a phase for starting a service worker. What is that phase called?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeNo unload handlers, ever

            Which HTTP response header can stop every script on a page, even a third party's, from adding an unload handler?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask where the file or page was kept, who could reuse it, and what made the cache miss.

              The browser's caches quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does the second console.log in this example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Where library.js came from on each visit, as DevTools said (the same in all 5 runs).
                const shop = "the network";
                const blog = "the network";
                const shopAgain = "the disk cache";
                
                console.log(`The shop: library.js came from ${shop}.`);
                console.log(`The blog, another site: it came from ${blog} again.`);
                console.log(`The shop again: it came from ${shopAgain}.`);

                Choose an answer to see the explanation.

              2. Question 2 of 8What does the last line of this example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Away, then back. Was the page restored (the same in all 5 runs), and how long did
                // the trip back take, from history.back() to pageshow (the median of 5 runs, in ms)?
                const plainPage = { restored: true, ms: 6 };
                const unloadPage = { restored: false, ms: 16 };
                
                const trip = (page) => `${page.restored ? "restored" : "loaded again"} in ${page.ms} ms`;
                console.log(`The page: ${trip(plainPage)}.`);
                console.log(`The page with an unload handler: ${trip(unloadPage)}.`);

                Choose an answer to see the explanation.

              3. Question 3 of 8Asha opens the shop's All teas page in a new tab. Where can Chrome find the stylesheet without the network?

                Choose an answer to see the explanation.

              4. Question 4 of 8A shop and a blog load the same script from the same CDN. Why did the blog download it again?

                Choose an answer to see the explanation.

              5. Question 5 of 8In Chrome, in which order are these checked for a file a page asks for?

                Choose an answer to see the explanation.

              6. Question 6 of 8The shop's service worker was stopped before Asha came back. Why did its answer take longer?

                Choose an answer to see the explanation.

              7. Question 7 of 8A big script was loaded three times in a private window. What did the code cache do?

                Choose an answer to see the explanation.

              8. Question 8 of 8What kept pages out of Chrome's back/forward cache in the course's runs?

                Choose an answer to see the explanation.

              Key takeaways

              • For a file, Chrome tries the memory cache, then a service worker, then the HTTP cache, and only then the network.
              • The memory cache lives in the page's process. The HTTP cache is shared by every tab, and DevTools calls it the disk cache.
              • Chrome splits its HTTP cache by site, so each site downloads a shared CDN file for itself.
              • A service worker answers only through its fetch handler. It's a little slower than the HTTP cache, and slower still when it has to start.
              • The code cache is made on the second load and used from the third. Keep big scripts stable.
              • The bfcache keeps the whole page, paused. Never add an unload handler. web.dev's advice: "Use the pagehide event instead."

              Remember the one-liner.
              Several caches can answer before the network: find which one served your file, and what made the nearer ones miss.

              Sources

              The facts on this page come from these docs and source files:

              Coming next: CDNs and the edge, also in Fast servers, caches, and networks. A CDN is a shared cache out on the network, close to your visitors. You'll see what makes it hit or miss: points of presence, hit ratios, cache keys, and edge logic.

              CompleteFrontend Clear concepts. Working examples.