# 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.
