Skip to content

Performance: JSON API responses are served uncompressed (history payload is large on slow links) #23

Description

@LarsLaskowski

Source: Project review 2026-07-13 (Performance / Security / Robustness), finding P1.
Category: performance · Severity: medium · Effort: medium

Problem

The HTTP layer (internal/httpapi/handlers.gowriteJSON) never compresses responses. With default config (60-minute window at 5-second polling = 720 points per series), GET /api/v1/metrics/history returns 7 fixed series plus one per mountpoint and two per network interface, each point serialized as {"t":"2026-07-12T18:00:00.123456789+02:00","v":12.4} (~50 bytes). A typical Pi with 2 disks and 2 interfaces produces roughly 13 series × 720 points ≈ 450 KB of JSON, re-sent in full every poll (dashboard: every 60 s; third-party pollers possibly more often). On a Pi Zero over Wi-Fi this is noticeable both in transfer time and CPU. Highly repetitive JSON like this compresses ~10×.

GET /api/v1/metrics is smaller but also repetitive (per-core arrays, package lists) and benefits too.

Fix

Add transparent gzip support using only the standard library:

  1. In internal/httpapi/middleware.go, add a withGzip middleware:
    • If the request's Accept-Encoding does not contain gzip, pass through unchanged.
    • Otherwise set Content-Encoding: gzip and Vary: Accept-Encoding, delete any Content-Length, and wrap the ResponseWriter so Write goes through a *gzip.Writer (compress/gzip). Flush/Close the gzip writer after next.ServeHTTP returns.
    • Pool gzip writers with a sync.Pool to avoid per-request allocation; gzip.NewWriterLevel(w, gzip.BestSpeed) is the right trade-off on a Pi.
    • The wrapper must still forward WriteHeader (the logging middleware's statusRecorder pattern in the same file shows how).
  2. In internal/httpapi/server.go, apply it to the API routes (wrapping the whole mux is fine too, but skip already-compressed content; the embedded assets are text, so compressing everything is acceptable and simplest).
  3. Do not compress /healthz (2-byte body; harmless either way — implementer's choice).

Acceptance criteria

  • curl -sH 'Accept-Encoding: gzip' -o /dev/null -w '%{size_download}\n' http://localhost:8080/api/v1/metrics/history is roughly an order of magnitude smaller than without the header, and the decompressed body is byte-identical JSON.
  • Requests without Accept-Encoding: gzip receive identity responses (backward compatible for naive API clients — this must not break existing /api/v1 consumers).
  • New handler test: request with Accept-Encoding: gzip returns Content-Encoding: gzip and a body that gunzips to the same JSON as an uncompressed request.
  • go test ./... -race passes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions