Repository navigation
[Performance] Compress web bundles with Brotli at build time instead of gzip on the fly #101250
Description
Problem
NewDot web assets travel S3 → CloudFront → Cloudflare → browser. The deploy job uploads dist as plain uncompressed files, and CloudFront gzips them on the fly, so every client receives gzip.
The production build is 96 JavaScript files, 41.5 MB raw (11.6 MB gzipped), plus an 8 MB WebAssembly module (3.2 MB gzipped). A first sign-in downloads 6.2 MB (main, vendors, authScreens) before the app can render. With 22 deploys in the last 30 days and main/authScreens rotating on nearly every deploy, returning users re-download up to 6.2 MB each time:
authScreens.bundle.js 2921 KB
vendors.bundle.js 1847 KB
main.bundle.js 1478 KB
Solution
Compress every js/css/html/json/svg/wasm/ttf file with Brotli level 11 at build time, upload the .br copies next to the originals, and let the CDN serve the .br copy to browsers that accept it. Every browser since 2017 supports Brotli; anything else keeps getting gzip exactly as today.
Measured on the current build (gzip 9 is what CloudFront serves today, byte for byte):
gzip 9 brotli 11 saving
canvaskit.wasm 3 215 KB 2 470 KB 23%
authScreens.bundle.js 2 921 KB 1 987 KB 32%
vendors.bundle.js 1 847 KB 1 399 KB 24%
main.bundle.js 1 478 KB 1 066 KB 28%
illustrations.bundle.js 787 KB 643 KB 18%
heicTo.bundle.js 604 KB 416 KB 31%
First sign-in (112 compressible files) 15.14 MB 11.34 MB -3.8 MB (25%)
Re-download after a deploy (main + vendors + authScreens) 6 246 KB 4 452 KB -1.8 MB (29%)
Time saved at different network speeds:
Speed Scenario 6.2 MB gzip Brotli saves
5 Mbit/s weak 3G, 4G 10 s 2.9 s
10 Mbit/s average 3G, poor 4G, congested Wi-Fi 5 s 1.5 s
30 Mbit/s typical 4G 1.7 s 0.5 s
100 Mbit/s 5G, home Wi-Fi 0.5 s 0.15 s
This is a download-time improvement only — the amount of JavaScript the browser parses and runs is unchanged. Images and woff2 fonts are excluded because they are already compressed internally.
Implementation steps
- Add a compression plugin to the
rsbuildconfig so everyjs/css/wasm/ttffile gets a.brtwin, compressed once with Brotli 11. The plugin compresses in parallel, so build overhead is ~15–25 s. - Upload the
.brfiles to S3 withContent-Encoding: brand the realContent-Type— a few extra lines indeploy.ymlnext to the existings3 cp. - In CloudFront: add a function that checks whether the browser accepts Brotli and, if so, serves
.js.brinstead of.js. Browsers that don't accept it keep getting gzip as today. - In Cloudflare: enable Brotli on the
new.expensify.comzone, otherwise Cloudflare keeps asking the origin for gzip only and step 3 never kicks in.
If NewDot hosting moves entirely to Cloudflare (dropping CloudFront/S3), steps 1 and 2 are unchanged and the CloudFront function becomes a Cloudflare Worker doing the same thing.
Rollback: remove the CloudFront function. Everything goes back to gzip on the fly and the .br files sit unused.
Decisions already settled in the thread
- Why build time, not on the fly: Brotli 11 is 60–150× slower than gzip, so it can't run per request.
- Why level 11 over 9: across
main/vendors/authScreens, Brotli 9 saves 20% vs gzip and Brotli 11 saves 28%. Level 11 costs 5–12 s per big chunk at build time, which is acceptable for a one-time cost. - Why Brotli over zstd: brotli 11 beats zstd 19 on all three main chunks (for example
authScreens: 2 032 KB vs 2 178 KB). - Decompression cost: gzip 9 is 7.3 ms vs brotli 11 at 8.5 ms for the whole bundle — ~1 ms extra, off the JS thread.
Slack thread: https://expensify.slack.com/archives/C05LX9D6E07/p1789482193542349
Metadata
Metadata
Type
Projects
- StatusShow more project fieldsDone
Activity
roryabraham commentedon Sep 18, 2026
Next step: awaiting PR from @collectioneur
Julesssss commentedon Sep 30, 2026
Deployed today.
melvin-bot commentedon Sep 30, 2026
@justinpersaud / @roryabraham @collectioneur
The solution for this issue has been 🚀 deployed to production 🚀 in version 9.4.99-2 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:
27 remaining items
justinpersaud commentedon Oct 22, 2026
I marked it ready for review now and assigned you both to review
roryabraham commentedon Oct 22, 2026
PR merged. I was hoping to CP it to staging and test, but there were conflicts that I didn't feel like resolving. So I suppose we'll see if it worked on staging tomorrow!
MelvinBot commentedon Oct 24, 2026
Status after [No QA] Serve the web build's Brotli 11 twins from the Cloudflare Worker reached staging (9.5.20-0, tested 2026-10-23):
The Worker serves the Brotli twins correctly, but Chrome, Edge and Firefox don't get them on staging. Those browsers send
zstdinAccept-Encoding. Cloudflare then decodes the twin and re-encodes it as zstd, somainis 1.46 MB instead of 1.13 MB. That's about the same as production today (1.45 MB zstd).test-staging sends
brfor the same request. So a Cloudflare compression setting on the staging and production hostnames probably prefers zstd. Someone with access to that Cloudflare setting needs to check it.roryabrahamcouldn't find it with their access.main-e27519ace5234214.bundle.json staging, byAccept-Encoding:Accept-Encodinggzip, deflate, br, zstd(Chrome)gzip, deflate, br(Safari)gzipidentityChecks that pass on staging
brbody decodes to the same 5.66 MB file.service-worker.jsare sent asbrwith a-brETag.-brETags inIf-None-Matchreturn304with no body, for everyAccept-Encodingtested./main-….bundle.js.br,.%62rand.BRreturn404..brreturns the app shell.br. That's expected, since earlier deploys didn't upload the twins.I tested with
curlonly, not in a browser.justinpersaudwill follow up on Monday.justinpersaud commentedon Oct 26, 2026
@roryabraham I updated the compression rule if you want to try again
roryabraham commentedon Oct 26, 2026
@MelvinBot retest and see if it's now working as expected on staging
MelvinBot commentedon Oct 26, 2026
Yes, it works on staging now. Chrome, Edge and Firefox get the Brotli 11 twins. With the Chrome
Accept-Encodingheader,mainis now 1.13 MB asbr. On Friday it was 1.46 MB aszstd.Files on staging, requested with the Chrome header (
gzip, deflate, br, zstd), tested 2026-10-26:main-9b0ae0deeaf48455.bundle.js-brvendors-ef86f26f1a53b40e.bundle.js-brheicTo-f009d6ccb336231a.bundle.js-brmain.f7d14882f3.css-brOther checks
gzip, deflate, br) also gets the same 1.13 MBbrbody formain.gzipalone gets 1.60 MB gzip, andidentitygets the 5.66 MB original.brbody has the same SHA-256 hash as the original file.If-None-Matchwith the-brETag returns304with no body.maingets Cloudflare's ownbrat 1.41 MB, with no-brETag.zstdnow gets the uncompressed file. Real browsers always also sendgziporbr, so this doesn't affect them.I tested with
curlonly, not in a browser.view run
melvin-bot commentedon Oct 26, 2026
@justinpersaud / @roryabraham @collectioneur
The solution for this issue has been 🚀 deployed to production 🚀 in version 9.5.20-5 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:
roryabraham commentedon Oct 26, 2026
@MelvinBot now that is on prod, check if it's working on production as well or @justinpersaud needs to make compression rule changes in prod as well
MelvinBot commentedon Oct 26, 2026
Yes, it works on production too.
justinpersauddoesn't need to change any more compression rules. Chrome, Edge and Firefox now get the Brotli 11 files on new.expensify.com. With Chrome'sAccept-Encodingheader,mainis 1.13 MB asbr. Before the deploy, it was about 1.41 MB.Files on production (9.5.20-5), requested with Chrome's header (
gzip, deflate, br, zstd), tested 2026-10-26:main-9b0ae0deeaf48455.bundle.js-brvendors-f654f236e5229163.bundle.js-brheicTo-f009d6ccb336231a.bundle.js-brmain.f7d14882f3.css-brOther checks
gzip, deflate, br) gets the same 1.13 MBbrfile formain.gzipalone gets a 1.60 MB gzip file.brfile is byte-for-byte the same as on staging.If-None-Matchwith the-brETag returns304with no body.I tested with
curlonly, not in a browser.view run
roryabraham commentedon Oct 26, 2026
Closing since this is complete with no payments due