Compression dictionaries
Send only what changed: shared dictionaries let a new version of a file compress against the old one. A returning visitor in Chrome, before and after a deploy: a bundle sent as the difference from the one they already have, a React upgrade, product pages with a dictionary of their template, a dictionary Chrome dropped, and one it never used.
- 01Follow the exchangeRead the offer, the ask and the answer:
Use-As-Dictionary, thenAvailable-Dictionary, thendczwithVary. - 02Ship delta updatesBuild a
dczcopy of each new bundle against the old one, and a separate dictionary for pages that share a template. - 03Roll it out safelyKeep dictionaries fresh, know which browsers and certificates use them, and let everyone else get Brotli.
Send only what changed
Asha shops at the Mint & Co. tea shop. Its sale page is drawn by one JavaScript file, a bundle. It holds React and the shop's own code, joined into one file. The bundle is minified: spaces and long names are squeezed out. On her first visit, her browser downloads the bundle and keeps it.
Then the shop changes a few words and adds a countdown: "Only 3 days left." It builds a new bundle and puts it live. That's a deploy. Asha comes back. Her browser still has the old bundle, and the new one is almost the same. So how much has to cross the network again?
The course recorded both visits. Here is the bundle each time, compressed with Brotli at level 11, as in the last lesson:
// The shop's bundle on the wire, in bytes, with Brotli 11.const firstVisit = 58282;const nextVisit = 58365; console.log(`First visit: Asha downloaded ${firstVisit} bytes.`);console.log(`Next visit, after a small change: ${nextVisit} bytes.`);- Line 1 is a comment. On the wire means the bytes that really travel over the network, after compression.
- Line 2 is the first visit: 58,282 bytes. Line 3 is the next visit, after the deploy: 58,365 bytes.
- Lines 5 and 6 print
First visit: Asha downloaded 58282 bytes.andNext visit, after a small change: 58365 bytes.
Here are the two visits side by side, with what Asha's browser kept.
The second download cost as much as the first. Asha's browser already held almost every byte of the new bundle: only 341 were new. Yet it paid for all of them again. Why?
Think back to gzip, Brotli, and Zstandard. A compressor packs a file by swapping repeated text for a pointer. A pointer is a short note that says "go back and copy that text again". But a pointer can only reach text inside the same file. Brotli couldn't say "this part is the same as the old bundle". The server didn't even know Asha had the old bundle.
Ravi wants to send Asha a paragraph from a novel. If she doesn't have the book, he has to copy it all out by hand. But they both own the same book. So he texts: "Page 12, line 4, but change the last word to tea." One short line, and Asha can rebuild it word for word.
- In real life: The book both friends own
- In HTTP: A compression dictionary: a file the browser and the server both have
- In real life: "Page 12, line 4, but change the last word"
- In HTTP: Pointers into the dictionary, plus the bytes that are new
- In real life: Copying the whole paragraph out by hand
- In HTTP: Sending the whole file again, compressed on its own
That shared book is a compression dictionary. It's a file that both the browser and the server have, so the compressor can point into it. MDN calls this Compression Dictionary Transport. It lets you "provide your own dictionary, which is especially applicable to a particular set of resources." For Asha, the best one is the old bundle. She still has it, and the new bundle is mostly the same.
The course ran both visits again, with the old bundle as the dictionary. This time the new bundle arrived as 578 bytes, not 58,365. Here is the whole story in one picture.
Let a file the visitor already has be the dictionary for the next one. Then send only what changed. The server offers the file as a dictionary. The browser keeps it, and names it on its next request. The server sends the difference to browsers that have it, and the usual compression to everyone else.
How the course recorded it
Every number in this lesson comes from Chrome loading a shop the course built:
- The bundle holds React 19.3.1 and the shop's own code, minified into 219.1 kB. It's the same bundle as in the last lesson.
- Chrome 156 loaded the shop over HTTPS and HTTP/2, on a slow link. Each round trip, a message to the server and its answer back, took 150 ms. Data came down at 1.6 megabits a second.
- Each run is one visitor: a first visit, then a deploy, then a next visit on a new connection.
- Each setup ran 5 times. Times are the median, the middle value of the 5. Sizes and headers were the same in each run.
Pick a setup and a visit to see what Chrome asked for, and what came back. The sections below explain each row.
- LCP656 ms
- /app.3c4d.js: Content-Encodingdcz
- /app.3c4d.js: on the wire578 B
- /app.3c4d.js: decoded219,212 B
- Accept-Encoding sentgzip, deflate, br, zstd, dcb, dcz
- Available-Dictionary sent:YU4XauMoPUSzmdaqedj8/6EYoLdvrIZH6dM5ggGvOW8=:
- Dictionary-ID sent"app.1a2b.js"
- Chrome's dictionary issuesnone
Only the shop's own code changed. The old bundle came with Use-As-Dictionary. On the next visit, Chrome named it, and the new bundle came as dcz. The shop ran on https://localhost. LCP is the median of 5 runs. Sizes, headers and issues were the same in every run.
How shared dictionaries work
A dictionary isn't a special kind of file. MDN: "A compression dictionary doesn't follow any specific format, nor does it have a specific MIME type. They are regular files." A MIME type is the label that says what kind of file something is, like text/javascript. RFC 9842 is the standard for dictionaries. It says "any HTTP response" can be one: a script, a page, or a text file made just for the job.
Here is the idea in a few lines of JavaScript. It's a toy, not how Zstandard works inside. The dictionary is a sentence from the shop's old code. The new text adds the countdown:
// The browser already has this sentence. It's the dictionary.const dictionary = "Everything in the shop is 20% off until Sunday.";// The new text arrives as a pointer into the dictionary, then the new words.const pointer = { from: 0, copy: 47 };const newWords = " Only 3 days left."; const copied = dictionary.slice(pointer.from, pointer.from + pointer.copy);console.log(`The pointer copied ${copied.length} characters from the dictionary.`);console.log(`The text is now "${copied + newWords}"`);- Line 2 is the text the browser already has: the dictionary.
- Line 4 is a pointer into it: start at character 0, and copy 47 characters. That's the "page 12, line 4" part.
- Line 5 is the only text that's new. It has to travel as it is.
- Line 7 follows the pointer, the way the browser rebuilds a file.
- Line 8 prints
The pointer copied 47 characters from the dictionary. - Line 9 prints
The text is now "Everything in the shop is 20% off until Sunday. Only 3 days left."
In the last lesson, each pointer reached back inside the same file. This one reaches into a file the browser got earlier. So the new text only has to carry the pointer and the new words. Real compressors find shared pieces anywhere in the dictionary, and squeeze the new words too. MDN puts it simply: "All strings that are common between v1 and v2 don't need to be included in the download for v2, because the browser already has them." A string is a piece of text.
For this to work, the browser and the server have to agree, one step at a time:
- The offer. The server sends a file with a note: "keep this as a dictionary for files like it."
- The store. The browser keeps the file, and remembers which later files it's for.
- The ask. Later, the browser asks for a matching file. It adds: "I have a dictionary for this one", and says exactly which.
- The answer. The server sends the new file compressed against that dictionary. A server that can't sends the usual compression.
- The rebuild. The browser follows the pointers into its dictionary, and gets the whole file back.
Brotli and Zstandard can both point into a dictionary. So there are two new names for a compressed response:
dcbmeans Dictionary-Compressed Brotli.dczmeans Dictionary-Compressed Zstandard.
Chrome accepted both. The course built its copies with Node's Zstandard, so each dictionary result in this lesson is dcz.
RFC 9842 describes two kinds of dictionary. The rest of the lesson takes them one at a time.
- The previous version of the same file, like Asha's old bundle. The RFC says it "enables delivery of a delta-compressed version of the changes". A delta is the difference between two versions.
- A separate dictionary of text that many files share. The RFC's examples are "common template HTML for similar pages across a site and common keys and values in API calls."
Use-As-Dictionary and Available-Dictionary
Now the same steps, as the headers Chrome and the shop really sent. Headers are short lines of text that travel with each request and response. The shop spoke HTTP/2, which packs headers in its own format. Here they're written as plain lines of text, with the ones that don't matter left out.
The offer: Use-As-Dictionary
On Asha's first visit, her browser asked for the old bundle:
GET /app.1a2b.jsAccept-Encoding: gzip, deflate, br, zstd 200 OKContent-Encoding: brUse-As-Dictionary: match="/app.*.js", id="app.1a2b.js"Cache-Control: max-age=31536000, immutable- Line 1 asks for the old bundle,
/app.1a2b.js. Each version of the bundle has its own name, like the hashed file names in HTTP caching. - Line 2 is
Accept-Encoding: the formats the browser can read. They're gzip, deflate, Brotli (br) and Zstandard (zstd). There's nodcbordczyet, because the browser has no dictionary for this file. - Lines 4 and 5 are the answer: here is the file, compressed with Brotli.
- Line 6 is the offer.
Use-As-Dictionarysays: "keep this file as a dictionary." - Line 7 keeps the file fresh for a year. You'll see below why that matters.
Line 6 has two parts:
match="/app.*.js"says which later files the dictionary is for. It's a URL pattern, and the*stands for any text. So/app.3c4d.jsmatches, and/styles.cssdoesn't. The pattern only ever covers the dictionary's own origin: the same scheme, host and port.id="app.1a2b.js"is a name the server picks. The browser will send it back, so the server can find the file more easily.
The ask: Available-Dictionary
After the deploy, the new page asks for the new bundle. Chrome has the old one stored as a dictionary, and the new name matches its pattern:
GET /app.3c4d.jsAccept-Encoding: gzip, deflate, br, zstd, dcb, dczAvailable-Dictionary: :YU4XauMoPUSzmdaqedj8/6EYoLdvrIZH6dM5ggGvOW8=:Dictionary-ID: "app.1a2b.js"- Line 1 asks for the new bundle,
/app.3c4d.js. - Line 2 now ends with
dcb, dcz. Chrome added them only because it had a matching dictionary. RFC 9842 says a browser without one "MUST NOT" offer them. - Line 3 is
Available-Dictionary. It names the dictionary by its hash. - Line 4 is
Dictionary-ID: theidfrom the offer, sent back word for word.
A hash is a fingerprint of a file's bytes. Change one byte, and the fingerprint changes. This one is a SHA-256 hash: 32 bytes, written as text between two colons. Why a fingerprint, and not just the file's name?
Back to Ravi and Asha. "Page 12, line 4" only works if they have the very same edition. In a newer edition, page 12 might hold other words, and Asha would rebuild the wrong text. So before Ravi sends page numbers, Asha tells him exactly which edition she has.
- In real life: "I have the third edition"
- In HTTP:
Available-Dictionary: the dictionary's hash - In real life: Ravi checks he has the same edition
- In HTTP: The server checks it has a copy made against that hash
- In real life: Different editions: he copies the paragraph out
- In HTTP: No match: the server sends ordinary Brotli
The hash does that job. RFC 9842 uses it "to make sure that the client and server are both using the same dictionary." A name can't promise that: the file at a URL can change, and keep its URL. So the id is only a help. The RFC says the server must not rely on it, and must check the hash.
The answer: dcz, and Vary
The shop had a dcz copy of the new bundle, made against that same hash. So it sent it:
200 OKContent-Encoding: dczVary: Accept-Encoding, Available-Dictionary- Line 1: here is the file.
- Line 2 is
Content-Encoding: dcz. It says: "this is Zstandard, compressed against the dictionary you named." - Line 3 is
Vary, from HTTP caching. It lists the request headers a cache must match before it reuses a stored copy.
Why does Vary matter here? A shared cache between the shop and its visitors, like a CDN, might keep this 578-byte answer. Without Vary, it could hand it to the next visitor, who has no dictionary and can't rebuild the file. So RFC 9842 says such a response "MUST include" a Vary header. With both headers in it, a cache reuses the copy only for a request with the same Accept-Encoding and Available-Dictionary.
That makes five headers, sent by two sides. Read each row as one header: who sends it, and what it says in plain words.
| Header | Who sends it | What it says |
|---|---|---|
Use-As-Dictionary | The server, with the file it offers. | Keep this file. Use it for later files that match the pattern. |
Available-Dictionary | The browser, on a later request. | I have a dictionary for this file. Here is its hash. |
Dictionary-ID | The browser, next to Available-Dictionary, if the offer had an id. | Here is the id you gave the dictionary. |
Content-Encoding: dcz | The server, with its answer. | This is Zstandard, compressed against the dictionary you named. |
Vary: Accept-Encoding, Available-Dictionary | The server, with its answer. | Caches may reuse this copy only for requests with the same two headers. |
A dictionary has to be fresh
One more rule is easy to miss. RFC 9842 says the dictionary "MUST be either fresh" or allowed to be served stale. Fresh means its Cache-Control lifetime hasn't run out, like milk before its best-before date in HTTP caching.
What about no-cache, which lets a browser keep a file but check it before each use? MDN says that with no-store or no-cache, "the browser never uses the response as a dictionary".
The course tried it: the playground's New code, dictionary sent no-cache. The old bundle came with the same offer, but with Cache-Control: no-cache. Chrome dropped the dictionary and raised an issue in DevTools' Issues panel. On the next visit, it never sent Available-Dictionary, and the new bundle came as Brotli. One of the exercises asks you to find the issue's name.
Why it matters: pages are often sent with no-cache, so they're checked each time. MDN warns that such a page "cannot offer itself as a dictionary for its next version". Give anything you offer as a dictionary a real lifetime, like the year on Asha's bundle.
Delta updates for JavaScript bundles
Back to Asha's bundle. Using the old version of a file as the dictionary for the next one is called delta compression. MDN: "Previous versions of files typically have lots of similar content, which is why they make excellent dictionaries."
Asha's grandfather keeps last week's newspaper by the door. This week's paper has the same TV guide, the same puzzle page and the same shop adverts. Only the news is new. Say the paper boy brought just the new stories, with a note: "the rest is as last week". His bag would be far lighter.
- In real life: Last week's paper, kept by the door
- In HTTP: The old bundle, stored as a dictionary
- In real life: This week's paper
- In HTTP: The new bundle
- In real life: Only the new stories, and "the rest is as last week"
- In HTTP: The
dczresponse: the changes, plus pointers into the old bundle - In real life: A neighbour who threw last week's paper away
- In HTTP: A browser without the dictionary, which gets the whole file with Brotli
Here is how much lighter Asha's next visit was:
// The new bundle on the wire, in bytes. The same in all 5 runs.const brotliAlone = 58365;const withDictionary = 578; const smaller = 1 - withDictionary / brotliAlone;console.log(`The dictionary made it ${Math.round(smaller * 100)}% smaller.`);- Line 2 is the new bundle with Brotli 11 alone: 58,365 bytes.
- Line 3 is the same bundle as
dcz, with the old bundle as the dictionary: 578 bytes. - Line 5 works out what share of the bytes the dictionary saved.
- Line 6 prints
The dictionary made it 99% smaller.
Only the shop's own code had changed. React, most of the file, was the same as before, so all of it was in the dictionary. Did Asha notice? Here is her Largest Contentful Paint, or LCP: the moment the biggest text or image on the screen appeared.
// Asha's next visit: the median LCP of 5 runs, in ms.const brotliAlone = 948;const withDictionary = 656; console.log(`The page's main content appeared ${brotliAlone - withDictionary} ms sooner.`);- Line 1 is a comment: each time is the median of 5 runs, in milliseconds (ms).
- Lines 2 and 3 are the next visit's LCP: 948 ms with Brotli alone, 656 ms with the dictionary.
- Line 5 prints
The page's main content appeared 292 ms sooner.
Where did 292 ms come from? This page is drawn by the bundle, so nothing on it can paint until the bundle arrives. The dictionary saved 57,787 bytes. On a link that carries 1.6 megabits a second, those bytes take about 289 ms. That's nearly all of the gain. Why it matters: on a slow link, every byte you don't send is time the visitor doesn't wait.
A bigger change: upgrading React
A small change made a tiny delta. What about a big one? The course tried a second deploy. This time the shop's code stayed the same, and React moved from a 19.2 build to version 19.3.1. The old bundle, with React 19.2, was the dictionary:
// After a React upgrade: the new bundle on the wire, in bytes.const brotliAlone = 58282;const withDictionary = 22979; const smaller = 1 - withDictionary / brotliAlone;console.log(`The dictionary made it ${Math.round(smaller * 100)}% smaller.`);- Line 2 is the new bundle with Brotli alone: 58,282 bytes. It's the very file Asha got on her first visit earlier, so the number is the same.
- Line 3 is the same bundle as
dcz, against the React 19.2 bundle: 22,979 bytes. - Line 6 prints
The dictionary made it 61% smaller.
More of the file had changed, so less of it was in the dictionary. LCP still came 172 ms sooner: 776 ms against 948. The dictionary saved 35,303 bytes, about 177 ms on this link. The rule: the less that changed, the smaller the delta.
Building the dcz copies
Who makes the 578-byte file? A server could compress on each request. But MDN advises that "for static resources it can be better to create them in advance at build time." A static file is the same for each visitor, like a bundle. That's what the course did. When it built the new bundle, it also wrote a .dcz copy, with the old bundle as the dictionary:
import { createHash } from "node:crypto";import { readFileSync, writeFileSync } from "node:fs";import { constants, zstdCompressSync } from "node:zlib"; const dictionary = readFileSync("dist/app.1a2b.js");const file = "dist/app.3c4d.js";const header = Buffer.concat([Buffer.from([0x5e, 0x2a, 0x4d, 0x18, 0x20, 0x00, 0x00, 0x00]), createHash("sha256").update(dictionary).digest()]);const body = zstdCompressSync(readFileSync(file), { dictionary, params: { [constants.ZSTD_c_compressionLevel]: 19 } });writeFileSync(`${file}.dcz`, Buffer.concat([header, body]));- Lines 1 to 3 load Node's tools for hashes, files and Zstandard.
- Line 5 reads the old bundle. It's the dictionary.
- Line 6 names the file to compress: the new bundle.
- Line 7 makes a 40-byte header: 8 bytes that are always the same, then the dictionary's SHA-256 hash, 32 bytes.
- Line 8 compresses the new bundle with Zstandard at level 19, using the old bundle as its dictionary.
- Line 9 writes the header and the compressed bytes into
dist/app.3c4d.js.dcz.
Here is the file it made, the one Asha downloaded.
The hash in the header is the same one the browser sends in Available-Dictionary. So the server's rule is simple. If a request names that hash, send the .dcz copy. If not, send Brotli 11. Before using any copy, the course checked it. Unpacked with its dictionary, it gave back the new bundle byte for byte.
One choice is left: how many old versions to build against. A visitor might skip a deploy, and come back with a bundle two versions old. MDN says you must choose whether to build deltas "for the last version only, or for the last X versions". A visitor whose bundle is older than all of them gets Brotli, as if there were no dictionary.
Custom dictionaries for HTML
A product page isn't like a bundle. Product 2 doesn't replace product 1: it's a different page. But the shop's product pages look alike. They share the same header, menu, footer and sign-up form. Only the name, price, description and a few details differ.
A tour guide gives each traveller the same phrasebook on the first day. After that, her daily notes are short: "Phrase 12, at 9:30, by the river." She never writes out the whole sentence. One book serves each note for the whole trip.
- In real life: The phrasebook every traveller carries
- In HTTP: The pages' dictionary: the shop's template, with the details left empty
- In real life: "Phrase 12"
- In HTTP: A pointer into the dictionary
- In real life: The time and the place
- In HTTP: The product's own name, price and description
- In real life: Handing out phrasebooks on the first day
- In HTTP: Fetching the dictionary once, while the browser is idle
That phrasebook is the second kind of dictionary: a separate dictionary, made of the text many files share. MDN suggests putting "common strings (for example, your HTML templates)" in a file of their own. The course's dictionary is the shop's product page with the product details left empty. It's 5,789 bytes.
How does the browser get it? Product 1's page points to it with one tag in its head.
<link rel="compression-dictionary" href="/pages.dict">rel="compression-dictionary" tells the browser this file is likely to help later. MDN: "The browser downloads this dictionary during idle time, and the response must include the Use-As-Dictionary header." Idle time is when the browser has nothing else to do. Here is the dictionary's own answer:
200 OKContent-Encoding: brUse-As-Dictionary: match="/product/*", match-dest=("document")Cache-Control: max-age=86400- Line 2: the dictionary travels with Brotli, like any text file.
- Line 3 offers it for each path under
/product/.match-dest=("document")narrows that to pages. A request's destination is what it's for: a page, a script, an image. - Line 4 keeps it fresh for a day, 86,400 seconds.
Then Asha opens product 2. Here is what the dictionary saved, and what it cost:
// Product 2 on the wire, in bytes. The same in all 5 runs.const brotliAlone = 1476;const withDictionary = 378;// The dictionary itself, downloaded once, with Brotli.const dictionary = 1256; const savedPerPage = brotliAlone - withDictionary;console.log(`Each product page saves ${savedPerPage} bytes.`);console.log(`The dictionary pays for itself after ${Math.ceil(dictionary / savedPerPage)} pages.`);- Lines 2 and 3 are product 2 on the wire: 1,476 bytes with Brotli alone, 378 bytes as
dcz. - Line 5 is the dictionary itself, compressed with Brotli on its way: 1,256 bytes, downloaded once.
- Line 8 prints
Each product page saves 1098 bytes. - Line 9 prints
The dictionary pays for itself after 2 pages.
The page shrank by three quarters. But LCP barely moved: 496 ms with Brotli, 488 with the dictionary. Why so little this time? Product 2 was already small. On this link, 1,098 bytes take about 5 ms. Why it matters: a dictionary pays most on large files that visitors download again and again.
Support and rollout
A browser that can't use dictionaries sees no change. It doesn't offer dcb or dcz, and the server sends Brotli as before. So which browsers can use them?
Chrome has used them since version 130. MDN's browser data lists Firefox only in preview builds, or behind a setting from version 145. Safari doesn't support them.
Even in Chrome, a stored dictionary is used only when four rules hold:
- HTTPS only. RFC 9842: dictionaries "MUST only be used in secure contexts (HTTPS)." The reason is the boxes in between, like proxies and firewalls. Over plain HTTP, they might handle a response packed with a dictionary the wrong way.
- The same origin. MDN: "Dictionaries must be same-origin with the resource using the dictionary." A file from another origin that's packed this way also needs CORS. CORS is the set of headers that let a page read another origin's responses.
- Kept like cookies. RFC 9842: browsers "MUST treat dictionaries in the same way that they treat cookies". So dictionaries are split by site, like the lost-property boxes in The browser's caches, and cleared when cookies are cleared. Otherwise a dictionary's hash could follow a visitor from site to site, like a tracking cookie.
- A real certificate, in Chrome. A site's certificate is the ID it shows for HTTPS. Chromium's source code allows stored dictionaries in two cases. One is a localhost URL, and the other is a site with "a certificate rooted at a standard CA root." A CA, a certificate authority, is a group that browsers trust to vouch for a site.
The last rule is the easiest to miss. The course's certificate was self-signed: made by the course itself, not by a CA. On localhost, that was fine. Then it ran the same shop on shop.test, with the same certificate: the playground's New code, dcz, on shop.test. Chrome raised no issue. But on the next visit, it never sent Available-Dictionary, and the new bundle came as Brotli.
Why it matters: test dictionaries on a real domain with a real certificate, and check they're really used. In DevTools' Network panel, click the file and look for two things:
Available-Dictionaryin the request headers.Content-Encoding: dczordcbin the response headers.
If they're missing, open the Issues panel. MDN notes that Chrome reports a dropped dictionary there. If it shows nothing, check the certificate.
Put together, a rollout for bundles has five parts:
- Send each bundle with
Use-As-Dictionaryand a longCache-Control. - At build time, make a
dczordcbcopy of each new bundle. Build it against the last version, or the last few. - Send that copy only when
Available-Dictionarynames its dictionary. Send Brotli otherwise. - Add
Vary: Accept-Encoding, Available-Dictionaryto each answer for that file. - Check in DevTools that the dictionary is really used.
Which dictionary fits?
Each case below is a file or a visitor. Ask: does the visitor have an old version of this file? Do many files share the same text? Or is there no dictionary to use at all?
- A JavaScript bundle you deploy every week
- A WebAssembly module updated with each release
- Product pages that share one template
- API replies with the same keys every time
- A visitor's very first visit
- A visitor on Safari
- A bundle sent with
Cache-Control: no-cache
Sort each case by the dictionary that helps it.
Common mix-ups
- "Dictionaries speed up the first visit." No. On a first visit there's no dictionary yet. The recorded first visits took the same time with and without one.
- "A dictionary has to be a special file." No. Any response can be one. Asha's old bundle was a plain JavaScript file.
- "The id tells the server which dictionary the browser has." No. The id only helps. The server has to check the hash in
Available-Dictionary. - "Any HTTPS site gets them in Chrome." No. On shop.test, with a self-signed certificate, Chrome never sent
Available-Dictionary. - "The browser keeps every dictionary it's offered." No. A dictionary has to be fresh. Sent with no-cache, it was dropped.
- "Dictionaries always make pages faster." No. Product 2's LCP was 496 ms with Brotli and 488 with the dictionary. The page was already small.
Practice exercises
Answer first, then use the hints.
Product 2 came as 1,476 bytes with Brotli alone, and 378 as dcz. What does the last line print?
// Product page 2 on the wire, in bytes. The same in all 5 runs.
const brotliAlone = 1476;
const withDictionary = 378;
const smaller = 1 - withDictionary / brotliAlone;
console.log(`The dictionary made it ${Math.round(smaller * 100)}% smaller.`);The dictionary made it 74% smaller. That's the three quarters you saw in the picture of the product pages.
Suppose the pages' dictionary were 3,000 bytes instead. What does the last line print?
// Product 2 on the wire, in bytes. The same in all 5 runs.
const brotliAlone = 1476;
const withDictionary = 378;
// What if the dictionary were bigger?
const dictionary = 3000;
const savedPerPage = brotliAlone - withDictionary;
console.log(`Each product page saves ${savedPerPage} bytes.`);
console.log(`The dictionary pays for itself after ${Math.ceil(dictionary / savedPerPage)} pages.`);The dictionary pays for itself after 3 pages. A bigger dictionary takes longer to pay back, unless it also saves more on each page.
The old bundle was sent with no-cache. Which reason did Chrome give for not keeping it as a dictionary?
WriteErrorExpiredResponse. The response wasn't fresh, so Chrome didn't keep it as a dictionary.
After the React upgrade, which Dictionary-ID did Chrome send?
"app.7e01.js": the id the React 19.2 bundle was offered with. Chrome sent it back word for word.
A Zstandard decoder keeps a window: the recent bytes its pointers can reach. Browsers that accept dcz must handle windows up to what size?
128 MB. The window must be at least 8 MB or 1.25 times the dictionary, whichever is greater, up to 128 MB.
Shared dictionaries were tried on the web once before. What was that earlier attempt called?
SDCH, Shared Dictionary Compression for HTTP. MDN says it was never widely supported.
Check your understanding
For each question, ask three things. Did the browser have a fresh, matching dictionary? Did it say so? And what did the server send back?
Question 1 of 7What does this React upgrade example print?
Read the code, then predict// After a React upgrade: the new bundle on the wire, in bytes. const brotliAlone = 58282; const withDictionary = 22979; const smaller = 1 - withDictionary / brotliAlone; console.log(`The dictionary made it ${Math.round(smaller * 100)}% smaller.`);Choose an answer to see the explanation.
Question 2 of 7In this product page example, what does the second line print?
Read the code, then predict// Product 2 on the wire, in bytes. The same in all 5 runs. const brotliAlone = 1476; const withDictionary = 378; // The dictionary itself, downloaded once, with Brotli. const dictionary = 1256; const savedPerPage = brotliAlone - withDictionary; console.log(`Each product page saves ${savedPerPage} bytes.`); console.log(`The dictionary pays for itself after ${Math.ceil(dictionary / savedPerPage)} pages.`);Choose an answer to see the explanation.
Question 3 of 7When does Chrome add dcb and dcz to Accept-Encoding?
Choose an answer to see the explanation.
Question 4 of 7What does Available-Dictionary carry?
Choose an answer to see the explanation.
Question 5 of 7The old bundle came with Cache-Control: no-cache. What happened on the next visit?
Choose an answer to see the explanation.
Question 6 of 7On shop.test, with the same self-signed certificate, Chrome never offered its dictionary. Why?
Choose an answer to see the explanation.
Question 7 of 7A cacheable response compressed with a dictionary needs which Vary?
Choose an answer to see the explanation.
Key takeaways
- A file the visitor already has can be the dictionary for the next one. Asha's new bundle came as 578 bytes instead of 58,365.
- The server offers it with Use-As-Dictionary. The browser names it by its hash in Available-Dictionary, and adds dcb and dcz to Accept-Encoding.
- Build dcz or dcb copies against the versions visitors are likely to have, and send Vary: Accept-Encoding, Available-Dictionary.
- A separate dictionary helps pages that share a template. Dictionaries pay most on large files.
- Keep dictionaries fresh, test them on a real certificate, and let every other browser get Brotli.
Remember the one-liner.
Let the old file be the dictionary, send only what changed, and keep the dictionary fresh.
Sources
The facts on this page come from these standards and docs:
- Compression dictionary transport, MDN.
- RFC 9842: Compression Dictionary Transport, the IETF.
- net/base/features.h, Chromium.
- rel="compression-dictionary", MDN.
- browser-compat-data 8.1.8: Content-Encoding dcb and dcz, MDN.
Coming next: Connections in practice. You'll reuse connections, cut third-party origins, and make HTTP/2 and HTTP/3 prioritization work for you.