5.8 KiB
Fix 02: compress and cache static responses
Priority: P0
Risk: Medium
Primary benefit: Network transfer and repeated navigation cost
Issue
The application uses Gin's static handlers for /static and /dist in internal/server/server.go. Asset URLs under /dist already include a version derived from modification time and size through assetURL, but responses have neither Cache-Control nor Content-Encoding.
Observed consequences:
- The 1.16MB player bundle was transferred uncompressed on first use.
- CSS and JavaScript were revalidated with
304 Not Modifiedon full-page navigations. - A cache-busting URL existed, but the response did not tell the browser it was safe to retain the asset immutably.
Desired behavior
- Versioned
/distassets are cached for one year as immutable. - Unversioned or user-specific responses are not given immutable caching.
- JavaScript, CSS, HTML, JSON, SVG, and text are compressed when beneficial.
- Video streams, HLS segments, range responses, and already compressed images are not buffered or recompressed.
Vary: Accept-Encodingis set when content negotiation is used.
Proposed design
Use two narrowly scoped middleware responsibilities:
- Static cache policy middleware for
/distand selected fingerprinted assets. - Compression middleware for ordinary textual responses, explicitly excluding playback proxy routes and unsuitable content types.
The static cache middleware must run before the static handler writes headers. A simple policy is:
/dist/...with a non-emptyvquery:public, max-age=31536000, immutable./dist/...withoutv:public, max-age=0, must-revalidateduring transition./static/...icons/images: use a shorter cache until their URLs are fingerprinted; then move them to immutable URLs.- HTML and authenticated JSON: no shared immutable caching.
Query-string versioning works in modern browsers, but content-hashed filenames are more robust with CDNs. Filename hashing can be a later improvement; it is not required to fix the present issue.
Compression options
Option A: middleware compression
Use a maintained Gin/Go compression middleware or a small custom wrapper. It must:
- Respect
Accept-Encoding. - Skip responses that already set
Content-Encoding. - Skip
HEAD,204,304, and range/partial responses. - Skip
/watch/proxy/streamand/watch/proxy/subtitleunless subtitle text is handled separately and safely. - Skip
video/*,audio/*, PNG, JPEG, WebP, AVIF, ZIP, and other already compressed formats. - Avoid compressing very small bodies where headers and CPU cost exceed savings.
Option B: precompressed build artifacts
Generate .gz and .br variants of production JS/CSS and serve the correct file by Accept-Encoding. This minimizes runtime CPU and is attractive for a single-server deployment, but requires a custom static handler and correct Vary/content headers.
Start with middleware compression unless profiling shows runtime CPU pressure. The application is modest and prioritizes operational simplicity.
Middleware ordering
Recommended order:
- Request context/request ID.
- Recovery and logging as currently required.
- Compression decision.
- Static cache policy.
- Route/static handler.
Confirm that the request logger records final status and bytes correctly when the writer is wrapped.
Security and correctness
- Do not apply public caching to authenticated HTML, public watchlist JSON with user data, or session-dependent fragments.
- Never cache signed playback proxy responses by a public key unless the token and upstream expiry model explicitly support it.
- Preserve
Content-Range,Accept-Ranges, and streaming behavior for media. - Add
Vary: Accept-Encodingto prevent an intermediary from serving gzip bytes to a client that did not request them.
Affected files
internal/server/server.go- New middleware file under
internal/server/ templates/funcs.goif asset versioning changes- Middleware and response-header tests under
internal/server/
Tests
Add table-driven tests covering:
- Versioned JS receives immutable caching.
- Unversioned JS does not receive immutable caching.
- HTML is not publicly cached.
- Text response compresses when gzip is accepted.
- Response remains plain when compression is not accepted.
Vary: Accept-Encodingis present.- Playback proxy and range responses are not compressed or buffered.
304andHEADresponses remain correct.
Verification
Use header checks:
curl -sSI 'http://localhost:3000/dist/static/app.js?v=test'
curl -sSI -H 'Accept-Encoding: gzip' 'http://localhost:3000/dist/static/player/main.js?v=test'
curl -sSI -H 'Accept-Encoding: gzip' 'http://localhost:3000/watch/proxy/stream?token=invalid'
Then use browser developer tools to confirm that a second full navigation uses memory/disk cache and does not revalidate versioned assets.
Observability
Track uncompressed versus compressed response bytes, selected content encoding, compression CPU time if available, static 200 versus 304 counts, and playback proxy/range errors. A successful rollout should sharply reduce transferred JS/CSS bytes and repeated 304 requests without changing media error rates.
Rollout
Ship cache headers and compression behind separate code paths so either can be reverted independently. Watch CPU, response bytes, error rates, and playback range-request behavior after release.
Acceptance criteria
- Versioned
/distassets return one-year immutable caching. - JavaScript and CSS are compressed for supporting clients.
- Repeated full-page navigation does not revalidate unchanged versioned assets.
- Playback streaming and range requests behave exactly as before.
- No authenticated response receives public immutable caching.