Все цифры в статье - с нашего клиентского портала (CRA-сборка, React 16 (да, посмейтесь, а я поплачу ещё раз)). Размеры и дельты измерены локально на 18 реальных релизных сборках, поведение пользователей - из прод-таблицы логинов за 30 и 100 дней. Строки таблицы результатов, которые не измерены, а посчитаны по модели, помечены отдельно. Методика - в конце.
У нас непрерывная поставка. В спокойный день один релиз, в шумный - восемь. За сто дней (4 мая - 10 августа) в клиентский портал уехал 81 релиз: 42 активных дня, в среднем 1,9 релиза в день, четверть релизов выходит меньше чем через два часа после предыдущего. Между двумя соседними сборками в бандле меняется хорошо если пара сотен строк: правка в компоненте, бампнутый минорный патч, флаг раскатили на сто процентов. Исходный main.*.chunk.js за шестнадцать релизов подряд вырос на 45 КБ - на десять мегабайт несжатого кода.
Весит этот бандл много. Основной чанк - 6,12 МиБ после brotli -q 11, вендорный - ещё 1,64 МиБ, итого 7,76 МиБ на холодную загрузку. Brotli тут почти бессилен: из десяти мегабайт main.js 7,8 МиБ - это заинлайненные base64-PNG, 110 штук. Уже сжатые данные второй раз не жмутся, ratio на этом чанке 1,63.
Дальше происходит вот что. Пользователь заходит утром и качает 7,76 МиБ. Заходит после обеда, а к этому моменту у нас уехал релиз, хеш в имени файла другой, и он качает 7,76 МиБ ещё раз. Вечером - снова. Медианный разрыв между двумя визитами одного клиента у нас 4,3 часа, 79% повторных визитов укладываются в сутки, и за день человек скачивает двадцать с лишним мегабайт, из которых новыми были килобайт пятнадцать.
Портал у нас клиентский, и клиенты бывают разные. Есть, условно, бедуин: продал верблюда, переложился в структурные ноты и теперь раз в сутки достаёт телефон посмотреть, как там поживает его пенсия. Смотрит он на два числа и цветную стрелочку. Чтобы их увидеть, он каждый раз тянет через спутник восемь мегабайт джаваскрипта, из которых новыми были две строчки в компоненте кнопки. За месяц он таким образом скачивает примерно верблюда.
Это не проблема кеширования. Кеш работает ровно как задумано: URL изменился, значит ресурс другой, значит качаем. Проблема в том, что HTTP до недавнего времени умел сжимать только внутри одного ответа. Похожесть двух ответов между собой протокол не использовал никак, а у нас эта похожесть - 99,8%.
Теперь умеет.
Механизм называется Compression Dictionary Transport и описан в RFC 9842.
Хронология тут выглядит вывернутой, поэтому сразу поясню. В Chromium фича приехала 15 октября 2024, в 130-й версии. RFC вышел почти на год позже, в сентябре 2025. Это не значит, что Chrome отгрузил что-то самопальное: реализация сделана по draft-ietf-httpbis-compression-dictionary-19 от августа 2024, и ровно этот черновик потом и напечатали как RFC 9842. Нормальный порядок вещей в IETF - браузеры выкатываются по стабилизировавшемуся драфту, публикация RFC это уже бюрократическая формальность в конце.
Практический вывод отсюда один. Всё, что писалось про фичу до драфта 19, содержит другие имена: content-encoding назывался br-d и zstd-d вместо dcb/dcz, заголовок запроса - Sec-Available-Dictionary вместо Available-Dictionary, хеш словаря ездил отдельным заголовком Content-Dictionary, а не внутри потока. Если вы гуглите конфиги и находите эти имена - статья устарела, копировать оттуда нечего.
Мы выкатили всё это весной и вместо бандлов начали отдавать дельты.
Сразу оговорюсь: почти все статьи про эту фичу написаны с расчётом на релизы раз в неделю. При нескольких релизах в день ломается не сама технология, а вся обвязка вокруг неё. Про обвязку тут будет больше, чем про заголовки.
Что это вообще такое
Brotli и Zstandard умеют сжимать с внешним словарём. Словарь - просто блоб байт, известный и упаковщику, и распаковщику заранее. Всё, что встретилось в словаре, в сжатом потоке заменяется на короткую ссылку.
У brotli такой словарь встроенный: примерно 120 КБ типичных веб-строк, накопленных при разработке формата. Именно поэтому brotli лучше gzip на мелких HTML и JSON.
RFC 9842 разрешает подсунуть свой словарь и описывает, как браузер с сервером договариваются, какой именно словарь у клиента есть.
Идея дальше простая: любой ответ, который браузер уже скачал, можно объявить словарём для будущих ответов. Вчерашняя сборка бандла - идеальный словарь для сегодняшней. Совпадение с ней не три процента, а девяносто девять.
Как выглядит договорённость
Три заголовка и одна link-релейшн, больше ничего.
Сервер помечает ответ как пригодный для роли словаря:
HTTP/1.1 200 OK Content-Type: application/javascript Cache-Control: public, max-age=31536000, immutable Use-As-Dictionary: match="/static/js/main.*.chunk.js", match-dest=("script"), id="a19c3f"
match - это URLPattern (регулярки спека запрещает), обязательно того же origin. match-dest ограничивает применение по типу запроса из Fetch: document, script, style, frame. id - непрозрачная для клиента строка до 1024 символов, сервер кладёт туда что угодно полезное для себя. Мы кладём хеш сборки, и дальше будет видно почему.
Браузер сохраняет ответ, считает от него SHA-256 и кладёт рядом с паттерном. Через три часа приезжает следующий релиз, URL матчится с паттерном, и запрос уходит вот таким:
GET /static/js/main.b41f9c.chunk.js HTTP/1.1 Accept-Encoding: gzip, deflate, br, zstd, dcb, dcz Available-Dictionary: :hwP3KkIDkfb2OVGfwfmleQnIFcfcwAzAtqa6QnkhurU=: Dictionary-ID: "a19c3f"
Available-Dictionary - Structured Field Byte Sequence, то есть base64 от хеша в двоеточиях. Хеш ровно один: если подходящих словарей несколько, клиент сам выбирает лучший (сначала по совпадению match-dest, потом по длине match, потом по свежести) и шлёт только его. Для нас это значит, что клиент всегда пришлёт хеш самой недавней виденной сборки, а не какой попало.
dcb и dcz в Accept-Encoding появляются только тогда, когда словарь есть. Удобный сигнал: по нему видно, что клиент готов принять дельту, ещё до разбора остальных заголовков.
Сервер отвечает:
HTTP/1.1 200 OK Content-Encoding: dcb Vary: accept-encoding, available-dictionary
dcb - Dictionary-Compressed Brotli: 36 байт фиксированного заголовка (0xff 0x44 0x43 0x42 плюс 32 байта SHA-256 словаря), дальше обычный shared brotli поток. dcz - то же для zstd, только заголовок 40 байт и оформлен как skippable frame, чтобы обычные zstd-декодеры не подавились.
Хеш продублирован внутри потока намеренно. Клиент обязан проверить, что распаковывает тем же словарём, которым сжимали, и не полагается на заголовки, которые мог переписать прокси.
Где сжимать
Три варианта, по возрастанию геморроя.
CDN-воркер. Если у вас Cloudflare, Fastly или что-то с исполнением кода на краю - самый короткий путь. У Patrick Meenan (соавтор RFC) лежит референсный dictionary-worker для Cloudflare на WASM-сборке zstd, он умеет и дельты, и standalone-словари. Cloudflare выкатывала passthrough-поддержку заголовков фазами, начиная с весны 2026.
Своё приложение. Технически возможно, практически тоскливо. Нужен shared brotli, а вменяемых биндингов под наш бэкенд нет. Zstd с --patch-from доступнее, но тащить тяжёлое сжатие в воркеры приложения всё равно не хочется.
Предсжатие в пайплайне + отдача статикой. То, что выбрали мы. Набор сборок известен, сжимать в рантайме нечего, отдача остаётся тупой раздачей файлов.
Наивная схема и почему она разваливается на пятом релизе
Первое, что приходит в голову: помечаем каждую сборку словарём, для каждой новой сборки генерим дельту к предыдущей, готово.
При релизе раз в неделю это работает. При нескольких в день - нет, потому что пользователь далеко не всегда приходит с предыдущей сборки. Он приходит с той, которую видел вчера вечером, а между ней и текущей уехало восемь релизов. Дельта к предыдущей сборке ему бесполезна: у него другой словарь, сервер этот хеш не знает, и человек снова качает восемь мегабайт.
Мы посчитали это на своих логинах. Из 8786 повторных визитов за месяц, которым реально пришлось идти в сеть за новым бандлом:
Насколько словарь отстал | Доля визитов |
|---|---|
1 сборка | 42,8% |
2-4 сборки | 35,5% |
5-8 сборок | 10,7% |
9-16 сборок | 5,9% |
больше 16 | 5,1% |
Дельта только к предыдущей сборке обслужила бы 43% из них. Значит, дельту надо иметь не к предыдущей сборке, а к каждой из последних N. Отсюда три вопроса, которых при недельных релизах просто не возникает.
Сколько сборок назад держать
Считаем по логам. Смотрим распределение времени между визитами (у нас это внутренний портал, люди возвращаются часто: медиана 4,3 часа, 93% визитов - в пределах трёх суток) и переводим его в сборки: сколько релизов успевает уехать между двумя визитами одного человека.
Из таблицы выше складывается покрытие:
Окно словарей | Доля визитов, попавших в окно |
|---|---|
1 сборка | 42,8% |
4 сборки | 78,3% |
8 сборок | 89,0% |
16 сборок | 94,9% |
Мы остановились на 16. Хвост дальше обслуживается обычным brotli: ничего не ломается, просто не ускоряется.
Дельты при этом почти ничего не весят, и это сильно упрощает жизнь. Дельта основного чанка к соседней сборке - 14,7 КБ против 6,12 МиБ полного ответа, к сборке трёхдневной давности (шестнадцать релизов назад) - 66,1 КБ. Шестнадцать дельт на чанк - 0,83 МиБ на релиз (а в релизе, где обновились зависимости и переехал вендорный чанк, 3,07 МиБ). Хранилище тут не ограничение вообще.
Отдельно любопытно, как ведут себя те самые заинлайненные картинки. Brotli на них бесполезен - они уже сжаты. А словарю всё равно, что за байты: между сборками картинки идентичны, и в дельту не попадают вовсе. Ровно поэтому 6,12 МиБ схлопываются в 15 КБ, а не в полтора мегабайта.
Ограничение - это время сборки
А вот brotli -q 11 -D на десятимегабайтном чанке занимает 3,2 секунды. Умножаем на 16 словарей, умножаем на количество чанков - и в CI появляется лишняя минута чистого CPU. При нескольких релизах в день это уже неприятно.
Решается тем, что генерация дельт не находится на критическом пути деплоя. Релиз выкатывается как обычно, с обычным brotli. Дельты досыпаются отдельной задачей после того, как трафик уже пошёл: пока файла нет, nginx отдаёт полный ответ, как только появился - начинает отдавать дельту. Ждать никого не надо.
Внутри задачи всё параллелится через xargs -P по числу ядер. Полный набор - 16 словарей на 2 чанка, 32 дельты в brotli и zstd - собирается за 27 секунд wall-clock на восьми параллельных задачах.
Если релизов ещё больше
Когда счёт идёт на десятки релизов в день, комбинаторика всё-таки начинает давить, и тогда переходят на якорные словари: Use-As-Dictionary вешается не на каждую сборку, а на одну выбранную в сутки, плюс отдельный словарь-файл раздаётся через <link rel="compression-dictionary"> для тех, кто эту сборку не застал. Клиенты держат один-два словаря на всех, дельта генерится одна на сборку. Ратио чуть хуже, обвязка втрое проще. Мы до этого пока не дошли.
Предсжатие руками
Обычный bash, ничего интересного. Собираем dcb вручную, потому что brotli CLI умеет сжимать с внешним словарём, но 36-байтный заголовок не приписывает:
#!/usr/bin/env bash set -euo pipefail NEW_BUILD=$1 # b41f9c - хеш текущей сборки KEEP=16 # сколько прошлых сборок держим как словари new="releases/$NEW_BUILD/main.js" for old_build in $(ls -1t releases/ | grep -v "^$NEW_BUILD\$" | head -n "$KEEP"); do old="releases/$old_build/main.js" out="deltas/main.$NEW_BUILD.from.$old_build.dcb" [ -f "$out" ] && continue { printf '\xff\x44\x43\x42' # 4 байта магии openssl dgst -sha256 -binary "$old" # 32 байта SHA-256 словаря brotli -q 11 -D "$old" -c "$new" # shared brotli поток } > "$out.tmp" && mv "$out.tmp" "$out" # переименование атомарно done
Проверить результат можно тем же brotli - отрезаем заголовок и распаковываем словарём:
tail -c +37 out.dcb | brotli -d -D "$old" -c | shasum -a 256 # должно совпасть с "$new"
У нас это 15 090 байт на входе и байт в байт исходные 10 489 232 на выходе.
Запись через временный файл с последующим mv тут не паранойя: nginx может начать отдавать файл ровно в тот момент, когда вы его дописываете.
Для zstd вместо магии 0xff 0x44 0x43 0x42 идут восемь байт 5e 2a 4d 18 20 00 00 00, а сжатие делается через --patch-from:
zstd -19 --long=27 --patch-from="$old" "$new" -o /tmp/patch.zst
На наших данных zstd-дельты стабильно на 10-18% крупнее brotli-дельт (17,3 КБ против 14,7 КБ к соседней сборке), так что dcb мы отдаём первым, а dcz - только тем, кто не прислал dcb.
С окном у zstd надо быть аккуратнее: спека требует, чтобы клиент вытянул минимум 8 МБ или 1,25 размера словаря (что больше), но не больше 128 МБ. Если бандл вырос относительно словаря сильнее чем на четверть, часть дельты просто не сожмётся. У brotli такой проблемы нет: словарь доступен целиком независимо от окна.
Отдача без перезагрузки nginx
Вторая вещь, которая при частых релизах становится критичной. В большинстве примеров из интернета маршрутизация делается через map с хешами словарей, который генерирует CI и который требует nginx -s reload. Несколько раз в день перезагружать конфиг ради нового словаря - так себе идея.
Обходится это через Dictionary-ID. Мы сами кладём туда хеш сборки, клиент обязан вернуть его как есть, и по нему сразу видно имя нужного файла. Ни map, ни reload.
# dcb клиент присылает только когда у него есть словарь map $http_accept_encoding $dcb_ok { default 0; "~*\bdcb\b" 1; } # Dictionary-ID - Structured Field String, приезжает в кавычках map $http_dictionary_id $dict_id { default ""; "~^\"(?<v>[a-f0-9]{6,40})\"$" $v; } map "$dcb_ok:$dict_id" $from_build { default ""; "~^1:(?<v>.+)$" $v; } server { root /var/www/dist; location ~ ^/static/js/main\.(?<build>[a-f0-9]+)\.chunk\.js$ { add_header Cache-Control "public, max-age=31536000, immutable" always; add_header Vary "accept-encoding, available-dictionary" always; add_header Use-As-Dictionary 'match="/static/js/main.*.chunk.js", match-dest=("script"), id="$build"' always; set $orig $uri; if ($from_build) { rewrite ^ /__delta/main.$build.from.$from_build.dcb last; } } location ^~ /__delta/ { internal; root /var/www; add_header Cache-Control "public, max-age=31536000, immutable" always; add_header Vary "accept-encoding, available-dictionary" always; add_header Content-Encoding dcb always; error_page 404 = @full; # дельты ещё нет - отдаём полный ответ } location @full { add_header Cache-Control "public, max-age=31536000, immutable" always; add_header Vary "accept-encoding, available-dictionary" always; try_files $orig =404; } }
Два замечания к этому куску. Первое: да, это if внутри location, тот самый if is evil. В связке с rewrite ... last он ведёт себя предсказуемо, но если некомфортно - то же самое на njs пишется в двадцать строк и читается лучше. Второе: на fallback-ветке @full заголовок Use-As-Dictionary теряется вместе с захватом $build, так что его надо продублировать через отдельный map по $orig. Мы про это узнали не сразу, а по нулевому проценту словарей в логах у части пользователей.
Валидация Dictionary-ID регуляркой [a-f0-9]{6,40} - не косметика. Это строка от клиента, и она подставляется в путь к файлу. Без ограничения символов вы получаете обход каталога в чистом виде.
Если клиент подсунет чужой, но валидный хеш сборки, ничего страшного не случится: браузер сверит SHA-256 внутри dcb-потока со своим словарём, не сойдётся - отбросит ответ. Сломает он этим только собственную загрузку.
Что ломается при непрерывной поставке
Перечислю то, что вылезло именно из-за частых релизов, а не из самой спеки.
Откаты. Откатились с b41f9c на a19c3f, и у половины пользователей в словарях лежит сборка новее той, которую вы теперь отдаёте. Дельта нужна в обратную сторону, а её никто не генерил. Мы решили это тем, что задача генерации работает не “от новой ко всем старым”, а “от всех живых ко всем живым в пределах окна”, и после отката просто догоняет недостающие пары.
Канареечные выкатки. Пока канарейка живёт, в проде одновременно две сборки, и пользователь может получить словарь от одной, а следующий запрос - к другой. Пары между канарейкой и основной сборкой обязаны существовать в обе стороны, иначе на канареечном трафике фича молча выключается.
Бампы зависимостей. Пока вендорный чанк не меняется, дельта к нему - 52 байта, то есть буквально “ничего не изменилось”. Как только в релизе приехала новая зависимость, вендорный чанк переезжает целиком, и дельта к нему подскакивает до 163 КБ - в одиннадцать раз больше дельты основного чанка. У нас это случилось ровно один раз за шестнадцать релизов и сразу утроило вес хранимых дельт. Считать окно надо по обоим чанкам, а не по одному main.
Ретенция. Старые сборки нельзя чистить агрессивно: пока у ассетов живой max-age, они у кого-то лежат в кеше и служат словарями. Файл самой старой сборки нужен вам ещё и как вход для brotli -D. Мы держим сырые сборки столько же, сколько окно словарей, плюс запас.
Фрагментация кеша. Vary: accept-encoding, available-dictionary разносит один URL на столько вариантов, сколько живых словарей у аудитории. При шестнадцати словарях это шестнадцать записей вместо одной на каждый чанк. Для edge-кеша это надо посчитать заранее, а не обнаружить по вытеснению. Если между вами и пользователем CDN - сначала убедитесь, что она вообще пропускает dcb и не решает пересжать ответ по-своему.
Грабли из самой спеки
Только HTTPS. Требование, не рекомендация. Мотивация - прозрачные прокси и DPI, которые могут не понять новый content-encoding и покалечить ответ.
Только Chromium. Chrome, Edge, Opera, Brave - с версии 130. У Safari и Firefox есть публичные планы, но на момент написания ни того, ни другого в проде нет. Экономия считается не от всего трафика, а от его хромой части - и вот тут стоит посмотреть на свою аудиторию, а не на общемировую статистику. У нас портал мобильный и очень iOS-тяжёлый: из 32 306 логинов за месяц на Chromium 130+ (не iOS, без Samsung Internet) приходится 44,9%, а больше четверти - вообще Safari на iOS, где Chrome под капотом тот же WebKit и словарей не поддерживает. То есть половина эффекта у нас недоступна по независящим от нас причинам.
Тот самый бедуин, разумеется, сидит с айфона. Верблюда он продал удачно, но словарей ему это не принесло: под капотом WebKit, и все наши дельты проезжают мимо. Так что за просмотр своей пенсии он по-прежнему платит полным бандлом. Зато теперь мы знаем точную цену вопроса, и она выражается в горбах.
Корпоративные прокси. На старте Chrome включал словари только там, где сертификат уходит корнем в общеизвестный доверенный центр, то есть за MITM-прокси фича молча выключалась. Привет, Минцифры, ЕВПОЧЯ. Ограничение было заявлено как временное, но если у вас заметная доля корпоративных пользователей - проверьте на своём трафике, а не по документации. Плюс существует enterprise-политика CompressionDictionaryTransportEnabled, которой админ может выключить всё это централизованно.
Свежесть словаря. Словарь работает, пока он свежий по HTTP-кешу (или пока разрешено отдавать stale). Короткий max-age на ассетах убивает всю схему. У нас immutable на год.
Партиционирование. Словари хранятся как куки: с разбиением по top-level site и чисткой вместе с кешем. Первый визит всегда без словаря, инкогнито - всегда без словаря. Это не баг, а защита от превращения хеша словаря в трекинг-куку.
Сжимать приватное общим словарём нельзя. BREACH и родственники никуда не делись, словарь тут дополнительный канал. Если в ответе есть и пользовательский ввод, и секреты - дельта-сжатие ему противопоказано. Спека отдельно требует отключать словарное сжатие для кросс-ориджин запросов с Sec-Fetch-Mode: cors, у которых не сходится CORS.
Что получилось
Метрика | До | После |
|---|---|---|
Основной чанк, | 6,12 МиБ | 6,12 МиБ (не меняется) |
Вендорный чанк, | 1,64 МиБ | 1,64 МиБ |
Полный набор JS+CSS на холодную загрузку | 7,76 МиБ | 7,76 МиБ |
Дельта к соседней сборке (main + vendor) | - | 14,8 КБ (0,19% полного ответа) |
Дельта к сборке трёхдневной давности, 16 релизов назад | - | 66,2 КБ, а если в окне был бамп зависимостей - 232 КБ |
Дельта вендорного чанка, когда зависимости не трогали | - | 52 байта |
Доля сетевых запросов за бандлом с | 0% | ~29% |
Из них попали в окно словарей (hit rate) | - | 94,9% |
Медианный возраст словаря, в сборках | - | 2 (p75 = 4, p90 = 9) |
Вес ассетов на повторный визит, пересёкший релиз | 7,76 МиБ | ~30 КБ (взвешенно по возрасту словаря) |
Время передачи этих ассетов на 10 Мбит/с * | 6,5 с | 0,03 с |
Исходящий трафик JS+CSS в сутки * | 3,75 ГБ | 2,74 ГБ (-27%) |
Прирост времени пост-деплой задачи | - | +27 с (32 дельты, |
Дельт на релиз в хранилище | - | 0,83 МиБ (3,07 МиБ в релизе с бампом зависимостей) |
* - посчитано по модели, а не снято с прода: доля Chromium 130+ и структура визитов взяты из логинов, объём трафика - произведение этих долей на измеренные размеры. Всё остальное измерено напрямую.
Средний размер ответа тут малоинформативен: смотреть надо на распределение по возрасту словаря. У нас 42,8% загрузок обслуживаются дельтой к предыдущей сборке (15 КБ), а 5,1% вообще не попадают в окно и качают полные 7,76 МиБ - и именно форма этого распределения определяет, какое окно словарей вам нужно.
Ещё одна вещь, которая видна только на своих логах: 73% повторных визитов вообще не идут в сеть за бандлом, потому что между визитами не было релиза. Дельты помогают не всем повторным визитам, а той четверти, которой не повезло попасть в окно между двумя релизами. Если считать экономию от всех визитов подряд - получите красивую, но неверную цифру.
Проверить, что фича вообще поддерживается клиентом:
document.createElement('link').relList.supports('compression-dictionary')
А на сервере - считать долю запросов с Available-Dictionary и долю тех из них, для которых нашёлся файл. Первая нулевая - не доезжает Use-As-Dictionary либо протух словарь. Вторая низкая - окно словарей маловато для вашей частоты релизов.
Когда не надо
Если релизы редкие, а между ними меняется половина бандла, дельта будет ненамного меньше полного ответа, а инфраструктуру вы построите на ровном месте.
Про картинки нюанс. Если основной вес страницы - отдельные запросы за картинками и видео, словарь не поможет: там уже сжато, причём с потерями, и между релизами эти файлы всё равно лежат в кеше по своим URL. А вот картинки, заинлайненные в бандл как base64, - наоборот, лучший аргумент за дельты: brotli их не жмёт, а меняются они куда реже кода, и словарь выносит их из ответа целиком. У нас именно этот случай и дал основную часть эффекта.
И если вы ещё не выжали brotli на статике, не поставили immutable на ассеты и не разнесли бандл на чанки - начните с этого. Дельты дают выигрыш поверх нормально настроенного пайплайна, а не вместо него.
Зато если вы деплоите по несколько раз в день, эффект получается заметно больше, чем описано в стандартных статьях про эту фичу. Там считают экономию для человека, который вернулся через неделю. У нас человек возвращается через четыре часа, и разница между “скачать 7,8 МиБ” и “скачать 15 КБ” видна даже без графиков. Особенно когда платить за неё приходится верблюдами.
Как считали
Чтобы цифры можно было перепроверить.
Размеры и дельты. Собрали 18 реальных релизных сборок портала (v1.0.134 - v1.0.150 подряд, плюс v1.0.163 как “далёкий” словарь) одним и тем же react-scripts build с GENERATE_SOURCEMAP=false. Мерили brotli 1.1.0 и zstd 1.5.7 на macOS, 14 ядер. Полный ответ - brotli -q 11, дельта - brotli -q 11 -D <старая сборка> плюс 36 байт заголовка dcb; для zstd - zstd -19 --long=27 --patch-from. Round-trip проверяли распаковкой обратно и сравнением SHA-256 с исходным файлом.
Поведение пользователей. Из прод-таблицы активности взяли события успешного логина клиентов: 99 079 повторных визитов за сто дней и 8786 визитов за месяц, которым пришлось идти за новой сборкой. Для каждой пары соседних визитов одного клиента посчитали, сколько релизных тегов легло между ними - это и есть возраст словаря в сборках. Браузер и версию брали из того же события (поле с user-agent), Chromium 130+ считали без iOS (там WebKit) и без Samsung Internet (лаг по версии Chromium).
Чего мы не мерили. RUM у нас на этом портале нет, поэтому в таблице нет реального времени до интерактивности - только время передачи, посчитанное из объёма. Доля запросов с Available-Dictionary тоже модельная: это доля Chromium 130+ среди визитов, которым нужен новый бандл, а не счётчик с фронта nginx.
Ссылки
RFC 9842, Compression Dictionary Transport - https://www.rfc-editor.org/info/rfc9842
RFC 9841, Shared Brotli Compressed Data Format - https://www.rfc-editor.org/info/rfc9841
MDN, Compression dictionary transport - https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Compression_dictionary_transport
Patrick Meenan, Getting Real (small) With Compression Dictionaries - https://calendar.perfplanet.com/2024/getting-real-small-with-compression-dictionaries/
Референсный воркер для Cloudflare - https://github.com/pmeenan/dictionary-worker
Генератор standalone-словарей - https://use-as-dictionary.com/generate/

