Skip to content

static: avoid recompressing unchanged files on every 200 response #289

Description

@nitedani

Idea

staticMiddleware() 0.12 fixed the immediate Brotli-11 CPU amplification problem by using quality 4 on the request path, bounding the file sizes it compresses, and supporting precompressed .br/.gz variants. Those are good safeguards.

There is still avoidable steady-state work in the default configuration, though: encodings defaults to false and compress defaults to true, so every unconditional 200 for the same unchanged compressible file is encoded again at quality 4. A conditional request can avoid this with a 304, and a build-produced variant is the best case, but neither helps a new client/shared-cache miss when an integration doesn't ship variants.

Would a bounded, opt-in compressed-representation cache (or a cache adapter hook) be in scope for srvx/static?

This follow-up came out of a real high-CPU incident in a Vike production deployment. The worst confirmed incident path was the older srvx@0.11 Brotli-q11 behavior, tracked in universal-deploy#34; I am not claiming the safer 0.12 q4 path caused that incident. The production fallout is why eliminating repeated encoding, rather than only reducing its quality, matters here.

Prior art: Shrink Ray's two-stage compression

@nitedani/shrink-ray-current has used a small and effective pattern for years:

  1. A cache miss streams a fast encoding to the first requester (Brotli quality 4).
  2. The compressed bytes are stored in a process-scoped, size-bounded LRU, keyed by content coding, URL, and ETag.
  3. The cached entry is asynchronously re-encoded at maximum quality (Brotli 11; the current fork uses zlib level 9 for gzip/deflate).
  4. Warm requests reuse the compressed bytes and perform no new compression.

Relevant implementation:

On Node 24.14.0, with a real 126,324-byte minified JS file:

response Brotli work wire bytes
first/cold q4 45,117
later/warm none; cached q11 39,489

Instrumenting createBrotliCompress() showed [4, 11] for the cold request (fast response plus background promotion) and no additional calls for two warm requests. In the same raw Node middleware harness (240 requests, concurrency 8, three runs), the median was 951.5 req/s with the cache disabled versus 1,982.0 req/s with the q11 cache warm. Absolute rates are machine-specific; the important property is that warm requests do not spend compression CPU and serve the smaller representation.

A srvx implementation can improve on the old middleware model

I wouldn't copy Shrink Ray literally:

  • Its generic Express interception happens after the downstream handler starts. A cache hit still executes the origin handler and discards the identity body. An integrated static server can check the representation cache after validating/statting the file but before reading its body.
  • It prevents duplicate insertion, not duplicate cold work. Eight simultaneous cold requests in my probe created eight q4 encoders and one q11 promotion. srvx can single-flight per representation.
  • Background maximum-quality encodes should be concurrency-limited or worker-backed so a burst of distinct cold assets cannot occupy the same libuv pool used by filesystem operations.
  • Its req.url key lets query-string cardinality create duplicate entries. A static server can key the canonical resolved file identity instead.

A possible native shape:

  1. Keep precompressed files as the first choice; build-time compression is still the cheapest and best result.
  2. For the on-the-fly fallback, key a bounded LRU by canonical source identity/version plus negotiated content coding (for example, the same metadata used for the representation ETag).
  3. Coalesce concurrent misses for that key.
  4. Stream q4 to the first requester while retaining the output; serve it directly on subsequent hits.
  5. Optionally promote the entry to q11 in a bounded background queue.
  6. Preserve the existing validator, abort, backpressure, range, and Vary behavior; set the encoded Content-Length on cache hits.

This could be disabled by default, use a modest byte limit, or be exposed as a cache hook so framework integrations can choose the policy.

Cross-repository context

The default self-hosted Vike +server path currently lands on srvx@0.11.22, so it still has the much worse Brotli-11-on-every-response behavior. The immediate dependency fix is tracked in:

After that upgrade, Vike/Universal Deploy still passes only { dir } to staticMiddleware, doesn't produce precompressed variants, and exposes prod.static only as boolean | string. Thus the current integration cannot opt into encodings: true, compress: false, maxAge, or immutable even though srvx supports them. That integration/build concern is separate, but it is what makes the on-the-fly fallback the effective production default there.

The 0.12 static work already calls out that repeated on-the-fly output isn't cached and recommends precompression. This issue is about making the generic runtime fallback efficient when there is no cooperating build step, using the same fast-first / best-later idea without the limitations of response interception.

Related ownership layers:

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions