[PR #641] [MERGED] perf: build-time precompression + startup metadata cache for static serving #737

Closed
opened 2026-05-06 13:09:52 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/641
Author: @NathanDrake2406
Created: 3/22/2026
Status: Merged
Merged: 4/1/2026
Merged by: @james-elicx

Base: mainHead: perf/precompressed-static-serving


📝 Commits (10+)

  • 0c7f808 perf: add build-time precompression for hashed static assets
  • 91dc43c perf: add startup metadata cache for zero-syscall static serving
  • 0b83607 perf: refactor tryServeStatic to async + cache + precompressed serving
  • 53f19d2 docs: fix stale comments in precompress (mention .zst)
  • 078746d fix: deduplicate buffer reads for HTML alias entries, fix stale JSDoc
  • bd0ea02 docs: fix stale comment in cli.ts (mention zstd)
  • 320466e fix: address code review findings
  • 6b4b1e1 fix: address remaining review findings in prod-server
  • a623e58 perf: move precompression to Vite plugin with edge auto-detection
  • 5b02a94 docs: fix inaccurate comments in precompress and static-file-cache

📊 Changes

8 files changed (+2043 additions, -80 deletions)

View changed files

packages/vinext/src/build/precompress.ts (+160 -0)
📝 packages/vinext/src/cli.ts (+8 -0)
📝 packages/vinext/src/index.ts (+106 -0)
📝 packages/vinext/src/server/prod-server.ts (+266 -80)
packages/vinext/src/server/static-file-cache.ts (+323 -0)
tests/precompress.test.ts (+195 -0)
tests/serve-static.test.ts (+668 -0)
tests/static-file-cache.test.ts (+317 -0)

📄 Description

This PR implements a static file serving method that is better than Next.js 16.2.

Bugs fixed

  1. Event loop blockingexistsSync + statSync ran on every static file request, blocking SSR responses behind synchronous filesystem calls. Now zero FS calls per request (metadata cached at startup).
  2. No 304 Not Modified — No ETag, no If-None-Match support. Every repeat visit re-downloaded every asset in full. Now returns 304 (200 bytes) when the browser already has the asset.
  3. HEAD returned full body — HEAD requests streamed the entire file body. Now sends headers only, per HTTP spec.

Optimizations

  1. Build-time precompression (opt-in).br (brotli q5), .gz (gzip l8), .zst (zstd l8) generated at build time. Zero compression CPU per request. Enable via precompress: true or --precompress CLI flag.
  2. Startup metadata cache — Pre-computed response headers per variant. Zero object allocation in the hot path.
  3. In-memory buffer serving — Small precompressed files (< 64KB) served via res.end(buffer) instead of createReadStream().pipe(), eliminating file descriptor overhead.
  4. Zstandard support — First Node.js framework to serve .zst assets. 3-5x faster client-side decompression than brotli (Chrome 123+, Firefox 126+).
  5. Async filesystem fallback — Non-cached files use fsp.stat() instead of blocking statSync.
  6. Filename-hash ETags — Hashed assets use the content hash from the filename for ETags (stable across deploys, Docker builds, CI). Non-hashed files fall back to mtime.

Real-world impact

  • SSR doesn't stall anymore. Before: 10 concurrent static file requests block the event loop with sync stat calls while SSR waits. Now: static serving is non-blocking with zero syscalls.
  • Repeat visits transfer almost nothing. Before: full asset re-download on every revisit. Now: 304 response (200 bytes) instead of the full bundle.
  • First visits transfer 70-80% less data. Before: raw uncompressed JS/CSS. Now: build-time brotli q5 or zstd. A 200KB bundle becomes ~50KB. On mobile, that's seconds saved.
  • Server CPU drops to near zero for static assets. Before: brotli compression on every request. Now: pre-compressed at build time, served from memory.
  • Build cost is negligible. fumadocs-docs-template: +30ms (1.2%) with precompression off, compression adds ~32ms for 100 assets when opted in.

Benchmark (100 assets, median of 5 runs)

  Precompression Benchmark
  Apple M4 · 10 cores
  5000 requests, 10 concurrent connections

  ┌─────────────────────────────────────────────────────────────────┐
  │  Build-time: compression speed (old vs new levels)             │
  └─────────────────────────────────────────────────────────────────┘

  Config                                Median               Range            Brotli              Gzip              Zstd
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
  Old (br:5, gz:9, zstd:22)             8027ms         7087-8905ms  262.1 KB (90.8%)  297.2 KB (89.6%)  225.3 KB (92.1%)
  New (br:5, gz:8, zstd:8)                32ms             27-33ms  262.1 KB (90.8%)  297.1 KB (89.6%)  241.5 KB (91.5%)

  Result: 253.6x faster (median of 5 runs)

  ┌─────────────────────────────────────────────────────────────────┐
  │  Serving throughput: vinext vs sirv vs send vs on-the-fly       │
  └─────────────────────────────────────────────────────────────────┘

  Server                       req/s       p50       p99   Transferred   vs send
  ──────────────────────────────────────────────────────────────────────────
  vinext (precompressed)       21032     0.4ms     0.8ms       11.8 MB      1.8x
  sirv (SvelteKit)             14076     0.6ms     2.0ms       12.8 MB      1.2x
  send (Next.js)               11611     0.8ms     2.0ms      139.0 MB      1.0x
  vinext (main)                 7904     1.1ms     4.1ms       13.2 MB      0.7x

  ┌─────────────────────────────────────────────────────────────────┐
  │  304 Not Modified (median of 5 runs)                            │
  └─────────────────────────────────────────────────────────────────┘

  Server                      Median req/s                 Range
  ──────────────────────────────────────────────────────────────
  vinext (precompressed)             25799           22989-26226
  sirv (SvelteKit)                   25535           25300-25784

fumadocs-docs-template build time

main (baseline):   2.530s ± 0.019s
branch:            2.560s ± 0.031s
delta:             +30ms (1.2%), ranges overlap

Feature comparison

vinext (this PR) SvelteKit / sirv Next.js / send vinext (before)
Per-request FS calls 0 0 1 (stat) 2 (existsSync + statSync)
Event loop blocking No No No Yes (sync stat)
Precompression zstd + brotli + gzip brotli + gzip none none
Compression quality build-time (br:5, gz:8, zstd:8) max (build-time) N/A fast (per-request)
Small file buffering Yes (< 64KB in memory) No (always streams) No (always streams) No
304 Not Modified Yes Yes Yes No
HEAD optimization Yes Yes Yes No
Zstandard Yes No No No
Content-Length Yes (all responses) Yes Yes No (compressed)
ETag Yes (filename hash) Yes (mtime) Yes No

Architecture

Build time:    precompressAssets()         → .br + .gz + .zst files on disk (opt-in)
Server boot:   StaticFileCache.create()    → scan dirs, cache metadata + buffers
Per request:   Map.get() → ETag check → res.writeHead(precomputed) → res.end(buf)

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/641 **Author:** [@NathanDrake2406](https://github.com/NathanDrake2406) **Created:** 3/22/2026 **Status:** ✅ Merged **Merged:** 4/1/2026 **Merged by:** [@james-elicx](https://github.com/james-elicx) **Base:** `main` ← **Head:** `perf/precompressed-static-serving` --- ### 📝 Commits (10+) - [`0c7f808`](https://github.com/cloudflare/vinext/commit/0c7f8088278d16afb039358afe1451141a09b8ab) perf: add build-time precompression for hashed static assets - [`91dc43c`](https://github.com/cloudflare/vinext/commit/91dc43c61cab072108d6b2641e9dc7b720db64e8) perf: add startup metadata cache for zero-syscall static serving - [`0b83607`](https://github.com/cloudflare/vinext/commit/0b8360708adebb1ddd583d03076ecba61ab2de3a) perf: refactor tryServeStatic to async + cache + precompressed serving - [`53f19d2`](https://github.com/cloudflare/vinext/commit/53f19d2b2bb720fa50e0c61a8cf30752f8a785cc) docs: fix stale comments in precompress (mention .zst) - [`078746d`](https://github.com/cloudflare/vinext/commit/078746dd5959f12e7ecf29dc0ee6d50ff1716f4d) fix: deduplicate buffer reads for HTML alias entries, fix stale JSDoc - [`bd0ea02`](https://github.com/cloudflare/vinext/commit/bd0ea02f26da1863d8fafc40e9344d0e83f6fbdc) docs: fix stale comment in cli.ts (mention zstd) - [`320466e`](https://github.com/cloudflare/vinext/commit/320466e36dfca56476e0d1d135d7050418a1b694) fix: address code review findings - [`6b4b1e1`](https://github.com/cloudflare/vinext/commit/6b4b1e18b21b5d186c589e034ea1795560c5bb0a) fix: address remaining review findings in prod-server - [`a623e58`](https://github.com/cloudflare/vinext/commit/a623e58d315fb609dfc31da130ce439f6835d20f) perf: move precompression to Vite plugin with edge auto-detection - [`5b02a94`](https://github.com/cloudflare/vinext/commit/5b02a945dfd5d1bdb14415d94600622b1da38fa7) docs: fix inaccurate comments in precompress and static-file-cache ### 📊 Changes **8 files changed** (+2043 additions, -80 deletions) <details> <summary>View changed files</summary> ➕ `packages/vinext/src/build/precompress.ts` (+160 -0) 📝 `packages/vinext/src/cli.ts` (+8 -0) 📝 `packages/vinext/src/index.ts` (+106 -0) 📝 `packages/vinext/src/server/prod-server.ts` (+266 -80) ➕ `packages/vinext/src/server/static-file-cache.ts` (+323 -0) ➕ `tests/precompress.test.ts` (+195 -0) ➕ `tests/serve-static.test.ts` (+668 -0) ➕ `tests/static-file-cache.test.ts` (+317 -0) </details> ### 📄 Description This PR implements a static file serving method that is better than Next.js 16.2. ### Bugs fixed 1. **Event loop blocking** — `existsSync` + `statSync` ran on every static file request, blocking SSR responses behind synchronous filesystem calls. Now zero FS calls per request (metadata cached at startup). 2. **No 304 Not Modified** — No ETag, no `If-None-Match` support. Every repeat visit re-downloaded every asset in full. Now returns 304 (200 bytes) when the browser already has the asset. 3. **HEAD returned full body** — HEAD requests streamed the entire file body. Now sends headers only, per HTTP spec. ### Optimizations 1. **Build-time precompression (opt-in)** — `.br` (brotli q5), `.gz` (gzip l8), `.zst` (zstd l8) generated at build time. Zero compression CPU per request. Enable via `precompress: true` or `--precompress` CLI flag. 2. **Startup metadata cache** — Pre-computed response headers per variant. Zero object allocation in the hot path. 3. **In-memory buffer serving** — Small precompressed files (< 64KB) served via `res.end(buffer)` instead of `createReadStream().pipe()`, eliminating file descriptor overhead. 4. **Zstandard support** — First Node.js framework to serve `.zst` assets. 3-5x faster client-side decompression than brotli (Chrome 123+, Firefox 126+). 5. **Async filesystem fallback** — Non-cached files use `fsp.stat()` instead of blocking `statSync`. 6. **Filename-hash ETags** — Hashed assets use the content hash from the filename for ETags (stable across deploys, Docker builds, CI). Non-hashed files fall back to mtime. ### Real-world impact - **SSR doesn't stall anymore.** Before: 10 concurrent static file requests block the event loop with sync stat calls while SSR waits. Now: static serving is non-blocking with zero syscalls. - **Repeat visits transfer almost nothing.** Before: full asset re-download on every revisit. Now: 304 response (200 bytes) instead of the full bundle. - **First visits transfer 70-80% less data.** Before: raw uncompressed JS/CSS. Now: build-time brotli q5 or zstd. A 200KB bundle becomes ~50KB. On mobile, that's seconds saved. - **Server CPU drops to near zero for static assets.** Before: brotli compression on every request. Now: pre-compressed at build time, served from memory. - **Build cost is negligible.** fumadocs-docs-template: +30ms (1.2%) with precompression off, compression adds ~32ms for 100 assets when opted in. ### Benchmark (100 assets, median of 5 runs) ``` Precompression Benchmark Apple M4 · 10 cores 5000 requests, 10 concurrent connections ┌─────────────────────────────────────────────────────────────────┐ │ Build-time: compression speed (old vs new levels) │ └─────────────────────────────────────────────────────────────────┘ Config Median Range Brotli Gzip Zstd ──────────────────────────────────────────────────────────────────────────────────────────────────────────── Old (br:5, gz:9, zstd:22) 8027ms 7087-8905ms 262.1 KB (90.8%) 297.2 KB (89.6%) 225.3 KB (92.1%) New (br:5, gz:8, zstd:8) 32ms 27-33ms 262.1 KB (90.8%) 297.1 KB (89.6%) 241.5 KB (91.5%) Result: 253.6x faster (median of 5 runs) ┌─────────────────────────────────────────────────────────────────┐ │ Serving throughput: vinext vs sirv vs send vs on-the-fly │ └─────────────────────────────────────────────────────────────────┘ Server req/s p50 p99 Transferred vs send ────────────────────────────────────────────────────────────────────────── vinext (precompressed) 21032 0.4ms 0.8ms 11.8 MB 1.8x sirv (SvelteKit) 14076 0.6ms 2.0ms 12.8 MB 1.2x send (Next.js) 11611 0.8ms 2.0ms 139.0 MB 1.0x vinext (main) 7904 1.1ms 4.1ms 13.2 MB 0.7x ┌─────────────────────────────────────────────────────────────────┐ │ 304 Not Modified (median of 5 runs) │ └─────────────────────────────────────────────────────────────────┘ Server Median req/s Range ────────────────────────────────────────────────────────────── vinext (precompressed) 25799 22989-26226 sirv (SvelteKit) 25535 25300-25784 ``` ### fumadocs-docs-template build time ``` main (baseline): 2.530s ± 0.019s branch: 2.560s ± 0.031s delta: +30ms (1.2%), ranges overlap ``` ### Feature comparison | | vinext (this PR) | SvelteKit / `sirv` | Next.js / `send` | vinext (before) | |---|---|---|---|---| | Per-request FS calls | **0** | **0** | 1 (`stat`) | 2 (`existsSync` + `statSync`) | | Event loop blocking | **No** | **No** | No | **Yes** (sync stat) | | Precompression | **zstd + brotli + gzip** | brotli + gzip | none | none | | Compression quality | **build-time (br:5, gz:8, zstd:8)** | max (build-time) | N/A | fast (per-request) | | Small file buffering | **Yes (< 64KB in memory)** | No (always streams) | No (always streams) | No | | 304 Not Modified | **Yes** | Yes | Yes | No | | HEAD optimization | **Yes** | Yes | Yes | No | | Zstandard | **Yes** | No | No | No | | Content-Length | **Yes (all responses)** | Yes | Yes | No (compressed) | | ETag | **Yes (filename hash)** | Yes (mtime) | Yes | No | ## Architecture ``` Build time: precompressAssets() → .br + .gz + .zst files on disk (opt-in) Server boot: StaticFileCache.create() → scan dirs, cache metadata + buffers Per request: Map.get() → ETag check → res.writeHead(precomputed) → res.end(buf) ``` --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 13:09:52 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#737
No description provided.