# ADR-0005: Reject in-app HTTP response compression **Date:** 2026-06-28 **Status:** Accepted ## Context LDR serves its front-end as Vite-built static bundles (~2 MB JS + ~435 KB CSS) through the custom `app_serve_static` route in `web/app_factory.py`. In a single-process deployment with no reverse proxy these assets are sent **uncompressed**, so a proposal (PR #3158, then a re-scoped PR #4832) added [`flask-compress`](https://github.com/colour-science/flask-compress) to compress them in-process with zstd/brotli/deflate, restricted to static MIME types. The change was implemented, adversarially reviewed, and ultimately **rejected**. Both PRs are closed; nothing was merged. ### Why the benefit is marginal - **Localhost is the default and the point of the tool.** Over loopback, transfer is effectively free; compression only spends CPU. Content-hashed bundles are already `immutable`-cached, so a browser fetches them once. - The win only materialises for users self-hosting LDR **remotely, for multiple users, with no reverse proxy** — a small slice of a "Local" research tool. - Those same users should run a reverse proxy anyway (for TLS), and **nginx / Caddy then provide compression *and* a disk-cached compressed artifact for free** — strictly better than re-compressing on every cold request in-process (`flask-compress`'s `COMPRESS_CACHE_BACKEND` defaults to `None`, so there is no compressed-output cache). ### Why the cost/risk is real In-app compression bolts an HTTP-correctness surface onto the app. Adversarial review found, and empirically reproduced: - **Malformed `Range`/`206` responses.** `flask-compress` has no range guard: a `Range` request with a compressible `Accept-Encoding` yields a `206` whose `Content-Range` describes the *identity* (uncompressed) coordinates while the body is compressed (e.g. `Content-Range: bytes 0-1023/14000` with a 23-byte zstd body). This is malformed per RFC 9110 and **corrupts conformant slicing intermediaries**: nginx's `slice` module resets the connection ("unexpected range in slice response"), CloudFront caches corrupt byte math, and `curl -C -` / download-manager resumes break. - **Varnish mis-serve.** Varnish (default `http_gzip_support=on`) deliberately ignores `Vary: Accept-Encoding`; with the standard "brotli through Varnish" VCL it can cache a brotli body and serve it to a gzip-only client. - **CDN re-opens BREACH.** Cloudflare and Fastly compress `text/html` themselves by default, so behind them the CSRF-token HTML is compressed regardless of an in-app MIME allow-list. (LDR's CSRF token is already per-render masked by Flask-WTF, which is the durable BREACH mitigation — not declining to compress in-process.) - **CPU amplification.** `app_serve_static` is public and `@limiter.exempt`; with no compressed-output cache, a client can force repeated compression of the ~2 MB bundle. Pre-change, static serving was a near-zero-CPU file send. The benefit accrues to a minority; several of the risks affect *more* setups (anyone behind a slicing CDN, Varnish, or a TLS-terminating CDN). That is a poor trade for a local-first tool. ## Decision **Do not add in-app HTTP response compression to the Flask application.** Do not re-introduce `flask-compress` or an equivalent runtime compressor. For remote / multi-user deployments, the guidance is to **front LDR with a reverse proxy** (nginx / Caddy), which handles TLS, compression, and caching — see [Deploying behind a reverse proxy](../deployment/reverse-proxy.md). If asset compression is ever genuinely wanted *without* a proxy, use **build-time pre-compression** instead: have Vite emit `.br` / `.gz` artifacts served as plain static files. That has zero per-request CPU cost, adds no runtime dependency, and — because the files are served plainly — avoids the content-negotiation, conditional-request, and byte-range edge cases above. ## Consequences - The codebase keeps a near-zero-CPU static file path and no new dependency (`flask-compress` + transitive `backports-zstd`). - Remote operators get compression from their reverse proxy/CDN, which is where it belongs. - Two of PR #3158's original items were unrelated and **already shipped**: static `Cache-Control` headers (#3185 / #3207) and removal of the duplicate `styles.css` link (#3207). Only the compression idea is rejected here.