Compression Dictionaries with Zstandard in PHP
One of the more interesting developments in the world of web performance in the last few years is Compression Dictionary Transport, RFC 9842. If you haven’t come across this yet, here is some background reading:
- Compression Dictionaries - Patrick Meenan
- Compression Dictionary Transport - MDN
- From Theory to Tiny: Implementing Compression Dictionaries, Ryan Townsend
Here is the short version of how I would describe it:
HTTP Compression Dictionaries are a way to make HTTP responses smaller by having the server and browser share a common text reference (the “dictionary”) when compressing a resource.
It is supported in Chromium based browsers, so this is something that you can use today. I’ve had it on my list of things to experiment with, but kept putting it off.
Then two things came together that pushed me to see if I could get a simple setup working with compression dictionaries. First, a few weeks ago I saw a post by Erwin Hofman mentioning he had implemented them. Second, having to hide away from everyone else during the 2025 end of the year holidays with the flu. The flu part is not a requirement for the instructions below, I highly recommend you avoid it.
The Setup
To see if I could get compression dictionaries to work I went with a fresh setup of Nginx and PHP on a Debian VM. I configured that with TLS & HTTP/2 via a cert from Let’s Encrypt. After confirming that was all working correctly I added the php-ext-zstd extension for PHP. That extension is what is doing all of the heavy lifting to make this work.
For my install I ran pecl install zstd and added the following three lines to the PHP-FPM php.ini file:
extension=zstd.so
zstd.output_compression=1
zstd.output_compression_level=3
With that you now Zstandard compression enabled for PHP output.
A Basic Dictionary Test
I had two goals. First, can I make this work? Second, given optimal conditions, how far can I push the compression numbers?
I created /var/www/html/zdict as a place to experiment, creating two file:
index.php: a single HTML page that comes in at 100KB with a bit of PHP at the top, this is what we want to compressdictionary.dict: this is the shared dictionary file, for testing purposes I created this as a copy of the 100KB HTML from index.php
The index.php had this little bit of code at the top:
<?php
if (
!empty( $_SERVER['HTTP_AVAILABLE_DICTIONARY'] )
&& !empty( $_SERVER['HTTP_DICTIONARY_ID'] )
) {
ini_set('zstd.output_compression_dict', __DIR__ . '/dictionary.dict' );
} else {
header( 'Link: </zdict/dictionary.dict>; rel="compression-dictionary"' );
}
header( 'Vary: Accept-Encoding,Available-Dictionary,Dictionary-ID' );
?>
If we get Available-Dictionary and Dictionary-Id headers in a request that means the browser already has a copy of the dictionary, so we need to tell the zstd extension to use the dictionary file when compressing the response. When we don’t get those headers we tell the browser where the dictionary file is.
To make the dictionary portion work I added these three lines to the Nginx config for the default site:
location ~ \.dict$ {
add_header Use-As-Dictionary 'match="/*", match-dest=("document"), id="/zdict/dictionary.dict"';
}
This adds the Use-As-Dictionary header to the dictionary file with the details about where it can be applied.
Results
With the above in place I get the following results on the initial request for /zdict/ ( no dictionary loaded yet ):
- Size: 102KB
- Transfer Size: 26.9KB
- Content Encoding: zstd
- Triggers an additional request for
/zdict/dictionary.dict
With the dictionary file now loaded the second page view shows:
- Size: 102KB
- Transfer Size: 0.2KB
- Content Encoding: dcz ( this is the type for dictionary compressed Zstandard )
- No additional request for
/zdict/dictionary.dict( because the PHP only adds thatLinkheader when the dictionary headers from the browser are not included in the request )
Getting down to 0.2KB is only possible via a dictionary that has all of the same content as the resource that is being compressed. Those are rediculously optimal conditions, requiring the browser to download twice as many bytes the first time.
As Robin Marx pointed out, finding the sweet spot for the contents of a shared dictionary file can be challenging. You don’t want it to be too big, because that is more the browser neeeds to download that doesn’t contribute to the content on the page. But you don’t want it to be so small that it doesn’t help push the compression numbers down.
Other Notes
The zstd extension for PHP made this easier than I expected.
Thank you Patrick Meenan for submitting the Add support for compression-dictionary-transport issue to the php-ext-zstd repo. That is how I first came across this extension.
You can use the URL chrome://net-internals/#sharedDictionary in Chrome to see the details about the compression dictionaries it has loaded. It has buttons for clearing out dictionaries, which is really helpful while testing.

