---
key: api.healthcheck-ttl-cache
title: 2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
rule: Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh.
signals: 'health check caching, TTL, refresh query param · paths: `HealthCheckService._memoryCache`, api-conventions.md §1/§3b · issues: #431, #164'
mechanics: '`PerformHealthChecks(forceRefresh, ...)`; `GET /api/v1/health?refresh=true`'
---

`HealthCheckService.PerformHealthChecks` re-ran all 14 checks on **every** call, four of which shell out to
`ffmpeg`/`ffprobe` via CliWrap — so a bare `GET /api/v1/health` spawned ~4 subprocesses per request. The
existing `HealthCheckSummary` cache was **write-only** (populated + published, never read back to short-circuit
a re-run). Harmless while the SPA Dashboard health panel refreshes on-demand only, but a real cost the moment
anything *polls* health (a status widget, an MCP client, monitoring). Split out of #164 as the orthogonal
performance half.

- **A short TTL cache of the full result list lives inside `HealthCheckService`.** A `_memoryCache` entry
  (`"healthcheck.results"`, `TimeSpan.FromSeconds(30)`) holds the last `List<HealthCheckResult>`; a non-forced
  call returns it directly on a hit, skipping both the 14 checks and the summary `Publish`. Chosen over
  "make the existing summary cache read-through" because the API returns the full per-check list, not the
  2-int summary — the summary entry (`"healthcheck.summary"`, read by `GetHealthCheckSummary`) is kept as-is
  (un-expiring) so its fallback behavior is unchanged.
- **`PerformHealthChecks` gained a `bool forceRefresh` first parameter** (interface signature change; one
  implementer, 3 live callers). `forceRefresh: true` bypasses the cache and repopulates it.
- **The refresh surface is an optional `?refresh=` query param on the existing GET**, following the
  `?deep=` bool-query-param exemplar (`api-conventions.md` §1/§3b) — additive, backward-compatible, no new
  endpoint. `[FromQuery] bool refresh` → `GetAllHealthCheckResultsForApi(Refresh)` → `PerformHealthChecks(request.Refresh, …)`.
  The SPA "Refresh health" button calls `/api/v1/health?refresh=true`; the initial/poll load calls the bare
  path (cached). A separate `POST …/refresh` endpoint was rejected as unnecessary surface for a read.
- **Who forces vs. who reads the cache:** the API GET poll path reads the cache; the **startup**
  `RunHealthChecksService` and the **troubleshooting** support bundle force a fresh run (both want current
  state — startup is a cold cache anyway, and a diagnostic bundle should reflect *now*, not a ≤30s-old poll).
  The legacy `GetAllHealthCheckResults` handler is dead (no senders) and reads the cache.
- **Thundering-herd on a cold cache was left out of scope** (no request-coalescing lock): polling is sequential
  per client and the TTL collapses steady-state load, so at most a handful of exactly-simultaneous cold callers
  re-run — a once-per-30s edge, not the repeated per-request cost the issue targets. Recorded here so a later
  reviewer doesn't read the absence of a `SemaphoreSlim` as an oversight.
