Case study

CaddyUI — Reaching a Perfect Lighthouse Score

  • Role Creator & maintainer
  • Stack Go, htmx, Tailwind, Caddy
  • Source X4Applegate/caddyui
  • Release v2.12.48 · April 30, 2026

Go htmx Tailwind Service worker Cloudflare Turnstile Docker multi-arch

99

Performance

100

Accessibility

100

Best practices

100

SEO

FCP 0.5 s · LCP 0.9 s · TBT 0 ms · CLS 0.001 · Speed Index 0.6 s, on Google PageSpeed Insights desktop against a self-hosted install behind a residential connection. Mobile sits at 76 on the simulated 4× CPU and slow 4G profile.

The eleven-version wave

CaddyUI is a self-hosted web UI for the Caddy server: proxy hosts, redirections, certificates and raw routes managed from a browser instead of a config file. Users kept saying “the page feels slow” and Lighthouse kept scoring the login page somewhere in the thirties. Rather than one big rewrite, each release from v2.12.38 to v2.12.48 attacked exactly one Lighthouse audit.

VersionChangeMobileDesktop
~v2.12.37Baseline~30–40~50–60
v2.12.38Cache-Control: max-age=86400 on /static/*~40~70
v2.12.39Self-host Inter font and htmx; service-worker rewrite that purges stale caches per release~50~80
v2.12.40Preload Inter, preconnect to the Tailwind CDN~5591
v2.12.41Move 28 KB of inline JS to /static/app.js, cached across navigations~55~95
v2.12.42Preload app.js; defer non-critical version and AI status calls~55~96
v2.12.43Precompiled auth.css for the login page; Cloudflare Turnstile replaces reCAPTCHA76~99
v2.12.44–45Section anchor pills on the proxy-host form, ordered by frequency, form reordered to match7699
v2.12.46aria-current, aria-label on icon buttons, skip-to-content link7699
v2.12.47Label and input associations on every unauthenticated page7699
v2.12.48<meta name="description"> for SEO 1007699

Why 99 and not 100: the last performance point is bandwidth-bound. Assets have to physically reach Google’s test servers from a residential ISP, and that round trip dominates the final point. Everything else hit 100.

Headline wins

Five changes moved the scores. The rest were polish.

1. Precompiled CSS for the login page (v2.12.43)

PageSpeed always lands on /login for anonymous users. That page loaded 124 KB of the Tailwind JIT runtime, over 600 ms of JavaScript parse on a throttled phone, to style 63 unique classes. A scoped Tailwind config now scans the five unauthenticated templates and emits a 24 KB minified auth.css, about 5 KB gzipped. The layout template splits delivery: authenticated pages keep the CDN runtime because they use over 300 classes, anonymous pages get the precompiled file. Mobile jumped from 41 to 76 in one release.

2. Service-worker rewrite (v2.12.39)

The old worker had a hard-coded cache name that never changed, so after every docker pull users kept receiving the previous release’s stylesheet. The rewrite stops caching static assets in the worker, since the new HTTP cache headers do that job correctly, stops caching HTML because CaddyUI is online-only, and bumps the cache name each release so activation purges stale state automatically.

3. Externalising inline JavaScript (v2.12.41)

A 600-line inline script in the layout template, covering the update badge, dropdowns, command palette, AI chat and keyboard navigation, was re-parsed on every page load. Moving it to /static/app.js keeps the same deferred behaviour but lets the browser parse it once per session. Total Blocking Time fell from 2,230 ms to 0 ms.

4. Self-hosting the font and htmx (v2.12.39)

The Inter variable font and htmx.min.js used to come from two third-party origins. They are now downloaded at Docker build time and embedded in the Go binary with embed.FS. That removes three CDN round trips on cold loads and keeps the UI working on networks that block those origins.

5. Cloudflare Turnstile instead of reCAPTCHA

reCAPTCHA cost roughly 927 ms of JavaScript execution on desktop and 3.6 s on mobile. Turnstile does the same job in 150–500 ms with no quota and no tracking. CaddyUI had supported it since v2.5.0, so the swap is a settings toggle, yet it is the single largest mobile-performance lever available.

Accessibility deep dive

The accessibility score was capped between 86 and 92 by three gaps the automated checker can detect. Two releases closed all of them.

aria-current

The active navigation link was visual only
Screen readers could not tell which page was current. The active link now carries aria-current="page", and decorative icon spans are hidden from assistive technology.

aria-label

Icon-only controls had tooltips, not names
Five buttons relied on title, which is a tooltip rather than an accessible name. Theme toggles, the search icon, the server picker and the account menu now have explicit labels.

skip link

Keyboard users had to tab through the whole sidebar
A visually hidden link at the top of the page becomes a focused pill and jumps straight to the main content, in both the sidebar and the centred-card layouts.

for / id

Labels were not associated with inputs
This was the actual score-blocking gap. Labels matched inputs visually but lacked for and id pairs, so the accessibility tree saw unlabeled fields. Fixed across login, setup, forgot and reset password, and invite acceptance, with the correct autocomplete hints added so password managers offer to save credentials.

Mobile versus desktop

Desktop reaches 99. Mobile stays at 76, and the gap is structural rather than a bug.

  • Four-times CPU throttling. Every parse and paint takes four times longer than on real hardware. The Tailwind runtime on authenticated pages alone costs about 600 ms on the simulator and about 150 ms on a real phone.
  • 1.6 Mbps simulated network. The 340 KB variable font consumes roughly 1.7 s of Largest Contentful Paint just downloading.
  • Residential time to first byte. Google’s test locations are hundreds of milliseconds from a home server. A CDN in front would shave about a second off every metric.

Real users on mobile see something far better than 76, especially on a second visit when assets are cached. Lighthouse measures the cold-load worst case.

Earlier in the v2.12 cycle

The performance wave landed on top of a feature cycle that was already substantial:

  • Command palette with global search across proxy hosts, redirections, raw routes and certificates.
  • Bulk multi-select and drag-to-reorder on every list page, with enable, disable and delete actions.
  • Multi-provider AI assistant supporting local Ollama, Ollama Cloud, Anthropic Claude and any OpenAI-compatible API, with auto-fill tool calling and conversation memory.
  • Managed DNS on redirections, closing a long-standing gap where redirect hosts had no DNS plumbing.
  • Wildcard DNS-01 certificates: type *.example.com and CaddyUI emits the matching TLS automation policy using a stored Cloudflare token.
  • Carbon Orange theme with cross-device sync, and section anchor pills on the proxy-host form.

Upgrade

docker compose pull && docker compose up -d

Migrations run automatically on start-up. Anyone upgrading from v2.12.38 or earlier should unregister the old service worker once in their browser, because the previous worker could serve stale assets even after a pull. Images are multi-arch on Docker Hub, built on a scratch base and running as a non-root user.

# pinned to this exact release (recommended)
docker pull applegater/caddyui:v2.12.48

# rolling tags
docker pull applegater/caddyui:latest
docker pull applegater/caddyui:stable

Lesson

One audit per release beats one big rewrite. Each version had a single measurable target, the changelog explains every step, and nothing regressed because every change was small enough to reason about.