Skip to content

Serve precompressed web app assets for slow links#437

Open
ymichael wants to merge 6 commits into
mainfrom
bb/investigate-bb-airplane-wifi-load-performance-thr_n764f59w3w
Open

Serve precompressed web app assets for slow links#437
ymichael wants to merge 6 commits into
mainfrom
bb/investigate-bb-airplane-wifi-load-performance-thr_n764f59w3w

Conversation

@ymichael

@ymichael ymichael commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Summary

This PR improves slow/high-latency web startup by serving the existing production app assets precompressed, without changing the initial JS/CSS request graph.

  • Generate .br and .gz sidecars during @bb/app production builds.
  • Serve /assets/* with br preferred over gzip when the browser advertises support.
  • Preserve immutable /assets/* cache headers and no-store HTML fallback behavior.
  • Keep Hono dynamic gzip compression as a fallback for non-precompressed responses.

The earlier code-splitting experiment is intentionally not included. It increased startup request count and did not materially reduce the delivered startup payload.

Measurement scripts used during investigation were removed from this PR; the branch now keeps only the product change and server regression coverage.

Cost / Tradeoffs

Area Cost
Build time Direct precompression step measured locally: 11.65s wall, 11.02s user, 0.43s sys for 394 assets. Full forced @bb/app Turbo build with precompression: 34.108s.
Repo size No checked-in generated compressed assets and no checked-in measurement scripts. Source-only repo cost is the precompression script plus server/static-cache code and tests.
Package/dist artifact size Base app dist: 17.92 MiB / 467 files. Sidecars add 7.57 MiB / 788 files: 3.45 MiB Brotli + 4.12 MiB gzip. Total dist becomes 25.48 MiB.
Runtime CPU Lower than dynamic compression on the hot static path: request handling selects an existing .br/.gz file and streams it. No per-request compression work for precompressed assets.
Runtime memory No asset cache is introduced. Memory impact should stay near the existing static file streaming path, plus small header/path-selection work per request.
Request handling One filesystem-sidecar selection per static asset request. Request count is unchanged; response Content-Encoding and Vary: Accept-Encoding are added when a sidecar is served.

Browser Throttling Used

Yes. Before/after measurements used a local Chrome/Playwright-style browser run with Chrome DevTools Protocol network emulation:

  • Network.setCacheDisabled({ cacheDisabled: true })
  • Network.emulateNetworkConditions({ latency: 600ms, downloadThroughput: 80 KiB/s, uploadThroughput: 30 KiB/s })
  • Timeout: 120s; settle after DOMContentLoaded: 500ms

For the before/after comparison, I used the same built JS/CSS graph and measured:

  • Before: copied dist with .br/.gz sidecars removed, simulating previous identity static serving.
  • After: copied dist with generated .br/.gz sidecars present, simulating this PR's static serving.

Measurements

Static graph accounting:

Variant Initial refs Initial requests Initial raw Initial gzip Initial Brotli Largest initial asset
Before / no sidecars 25 26 3.34 MiB 1.01 MiB 852.5 KiB theoretical diff-worker-pool: 2.07 MiB raw / 645.0 KiB gzip / 509.2 KiB br
After / sidecars served 25 26 3.34 MiB 1.01 MiB 852.5 KiB served when br accepted diff-worker-pool: 2.07 MiB raw / 645.0 KiB gzip / 509.2 KiB br

Real browser/CDP airplane profile:

Metric Before: identity static serving After: precompressed static serving Delta
Request count 22 22 0
Transfer size 3,478,983 B 855,504 B -2,623,479 B
Encoded body size 3,472,383 B 848,904 B -2,623,479 B
Decoded body size 3,472,383 B 3,472,383 B 0
Content-Encoding counts identity: 22 br: 21, identity: 1 Brotli served
First paint 11.32s 3.59s -7.73s
FCP 46.08s 12.98s -33.10s
DOMContentLoaded 45.97s 12.88s -33.10s
Elapsed measurement window 46.57s 13.45s -33.13s

Largest startup resources in the browser run:

Asset Before transfer After transfer Before duration After duration
diff-worker-pool-CzIm9giF.js 2,170,024 B 521,707 B 44.90s 11.78s
index-B9ZDeg52.js 465,896 B 121,324 B 20.59s 5.51s
page-shell-v8a65niI.js 399,673 B 99,219 B 19.29s 5.03s
index-CzQEJdK0.css 147,726 B 19,590 B 10.48s 2.71s
responsive-overlay-D_kX1YMN.js 71,884 B 20,824 B 5.15s 1.74s
dropdown-menu-BHhHHxNb.js 65,873 B 19,432 B 6.19s 2.71s
host-display-Bq0L7hZQ.js 55,009 B 14,630 B 9.09s 3.77s
thread-queries-DOOopSrx.js 30,930 B 8,333 B 9.59s 4.77s

Code-Splitting Prototype Findings

I prototyped deferring the root compose secondary-panel/file-preview/diff-worker path.

What it tried:

  • Moved file preview/diff worker imports behind dynamic imports.
  • Replaced the root compose secondary panel with a lighter shell.
  • Tried focused bundler splitting and then a lazy root-route shell fallback.

Why the initial attempt was ineffective/counterproductive:

  • The direct diff-worker-pool import disappeared, but the large shared graph was repeatedly hoisted into other initial chunks (threadWorkspaceOpenPath, useThreadSecondaryPanelVisibility, threadSecondaryPanelSelection, ThreadTerminalPanel, useThreadFileTabs, then secondaryPanelTabState).
  • Initial request count rose materially. One focused split produced 54 initial requests and 1.05 MiB Brotli, worse than this PR's 26 requests / 852.5 KiB Brotli graph.
  • The best lazy-root prototype reduced initial shell bytes to 663.1 KiB Brotli, but raised initial requests to 53 and delayed real composer code. Under the airplane profile the shell FCP was ~11.4s, but the full route then pulled more code, reaching ~925 KB transfer after long settle.

Why this is hard here:

  • Root compose, thread detail, secondary panel tabs, terminal tabs, browser tabs, file preview, open-in-editor, and diff selection actions share helpers heavily.
  • Vite/Rolldown correctly hoists shared dependencies, so local dynamic imports alone tend to rename the large startup chunk rather than remove it.
  • On high-latency links, request count matters almost as much as bytes; naive chunk splitting can be worse even when a single chunk shrinks.

Recommendation: do not include the code-splitting prototype in PR #437. Keep this PR scoped to precompressed static serving. Revisit JS graph reduction separately with a hard requirement that request count does not increase materially and that full-composer readiness improves, not only fallback shell paint.

Verification

Final branch is mergeable: gh pr view 437 --json mergeStateStatus reports CLEAN.

Commands run after removing the measurement scripts:

  • git diff --check
  • pnpm exec turbo run typecheck --filter=@bb/app --filter=@bb/server --force
  • pnpm exec turbo run test --filter=@bb/server --force -- test/app/static-cache.test.ts

Earlier final-branch verification before script removal, still applicable to the product/server changes:

  • pnpm exec turbo run build --filter=@bb/app --force
  • pnpm exec turbo run build --filter=@bb/server --force
  • pnpm exec turbo run test --filter=@bb/app --filter=@bb/server --force

Results:

  • Current app/server typecheck passed.
  • Current server static-cache regression test passed.
  • Earlier @bb/app tests passed: 165 files / 1110 tests.
  • Earlier @bb/server tests passed: 100 files / 809 tests.

@ymichael ymichael changed the title Improve web app startup resilience on slow networks Serve web app assets with gzip compression Jun 28, 2026
@SawyerHood

Copy link
Copy Markdown
Collaborator

You might be able to shave off an extra 100k by using brotli

@ymichael ymichael changed the title Serve web app assets with gzip compression Serve precompressed web app assets Jun 28, 2026
@ymichael ymichael changed the title Serve precompressed web app assets Serve precompressed web app assets for slow links Jun 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants