perf(web): optimize PWA startup - #184
Conversation
The dashboard showed a blank white page for seconds on open because the single-threaded ESP32 web server serializes every request and the page blocked first paint on two render-blocking resources: - style.css (16 KB) was render-blocking in <head> — nothing painted until it arrived, and the dark theme (and thus any visible content) only exists in CSS. - app.js (55 KB) was parser-blocking at the end of <body> — HTML parsing halted until it downloaded and executed. - The service worker used network-first, so even repeat visits with a warm cache waited for the network round-trip to the device. Changes: - sw.js: switch to stale-while-revalidate. Repeat visits render instantly from cache while assets refresh in the background (with cache: 'no-store' so freshly uploaded assets are picked up). API calls stay network-only. Cache bumped to pool-ctrl-v2. - index.html: inline critical first-paint styles (dark background, base text) and load /style.css asynchronously via the media=print onload pattern, so the page paints themed immediately instead of white. app.js now loads with defer, unblocking HTML parsing. - WebPortal.cpp: add Cache-Control headers — no-cache for the entry page and sw.js, public max-age=3600 for the static assets — so the browser HTTP cache backs up the service worker.
Native Test Coverage
Report from native unit tests (ASan + gcov). |
✅
|
| Descriptor | Linter | Files | Fixed | Errors | Max errors | Warnings | Elapsed time |
|---|---|---|---|---|---|---|---|
| ✅ ACTION | actionlint | 8 | 0 | 0 | 0.76s | ||
| ✅ BASH | bash-exec | 2 | 0 | 0 | 0.62s | ||
| ✅ BASH | shellcheck | 2 | 0 | 0 | 0.65s | ||
| ✅ BASH | shfmt | 2 | 0 | 0 | 0.01s | ||
| ✅ C | clang-format | 1 | 0 | 0 | 0.04s | ||
| ✅ C | cppcheck | 1 | 0 | 0 | 0.03s | ||
| ✅ C | cpplint | 1 | 0 | 0 | 0.29s | ||
| ✅ CPP | clang-format | 82 | 0 | 0 | 0.54s | ||
| ✅ CPP | cppcheck | 82 | 0 | 0 | 6.09s | ||
| ✅ CPP | cpplint | 82 | 0 | 0 | 7.24s | ||
| ✅ EDITORCONFIG | editorconfig-checker | 262 | 0 | 0 | 0.72s | ||
| ✅ JSON | jsonlint | 6 | 0 | 0 | 0.13s | ||
| ✅ JSON | v8r | 6 | 0 | 0 | 4.35s | ||
| markdownlint | 103 | 3 | 0 | 4.24s | |||
| ✅ YAML | yamllint | 25 | 0 | 0 | 0.84s |
Detailed Issues
⚠️ MARKDOWN / markdownlint - 3 errors
.opencode/skills/web-ui/SKILL.md:34 error MD028/no-blanks-blockquote Blank line inside blockquote
docs/superpowers/plans/2026-08-10-olimex-c6-local-ui-implementation.md:106:401 error MD013/line-length Line length [Expected: 400; Actual: 452]
docs/superpowers/plans/2026-08-16-norvi-button-calibration.md:7:401 error MD013/line-length Line length [Expected: 400; Actual: 412]
Notices
MAKEFILE, MAKEFILE_CHECKMAKE, MARKDOWN_MARKDOWN_LINK_CHECK. See Removed linters to find their replacements.
See detailed reports in MegaLinter artifacts
Your project could benefit from a custom flavor, which would allow you to run only the linters you need, and thus improve runtime performances. (Skip this info by defining FLAVOR_SUGGESTIONS: false)
- Documentation: Custom Flavors
- Command:
npx mega-linter-runner@10.0.0 --custom-flavor-setup --custom-flavor-linters ACTION_ACTIONLINT,BASH_EXEC,BASH_SHELLCHECK,BASH_SHFMT,C_CPPCHECK,C_CPPLINT,C_CLANG_FORMAT,CPP_CPPCHECK,CPP_CPPLINT,CPP_CLANG_FORMAT,EDITORCONFIG_EDITORCONFIG_CHECKER,JSON_JSONLINT,JSON_V8R,MARKDOWN_MARKDOWNLINT,YAML_YAMLLINT

Show us your support by starring ⭐ the repository
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b5aef58108
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A cache hit resolves caches.match() to a Response, not a Promise. The previous code assigned that Response to cacheHit and called .then() on it, throwing a TypeError that rejected respondWith — so repeat visits with a warm cache failed to load instead of rendering instantly. Addresses PR #184 review comment. Reviewed-by: Codex
The background refresh (fetch + cache.put) was neither awaited nor passed to event.waitUntil, so the browser could terminate the worker before the cache write finished, leaving freshly uploaded assets stale across reloads. The cache write is now awaited and the whole refresh promise is registered with event.waitUntil to extend the worker lifetime. Addresses PR #184 review comment. Reviewed-by: Codex
Native Test Coverage
Report from native unit tests (ASan + gcov). |
Fixes MegaLinter editorconfig-checker failure on PR #184. Reviewed-by: Codex
Native Test Coverage
Report from native unit tests (ASan + gcov). |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cefd262132
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The navigation fallback served the cached dashboard shell for any uncached HTML request, so a first navigation to the server-rendered /login page incorrectly showed the dashboard until a second visit. The fallback now only applies to dashboard-owned routes (/, /index.html); other HTML routes fall through to the network fetch. Addresses PR #184 review comment. Reviewed-by: Codex
Native Test Coverage
Report from native unit tests (ASan + gcov). |
Page load fired loadTelemetry, loadConfig and loadSensors at once, queueing three requests on the single-threaded device server and delaying the dashboard data. Only loadTelemetry is needed for the dashboard; config and sensor data are now fetched on first activation of the respective tabs. Init also runs immediately after the deferred script parses the DOM instead of waiting for window.onload.
The ESP32 web server streams uncompressed assets, so a first visit transfers ~112 KB (HTML 34 + CSS 16 + JS 56 + misc). scripts/ gzip-web-assets.sh pre-compresses the assets into .gz files (gitignored) that the firmware serves with Content-Encoding: gzip when present, cutting the transfer to ~28 KB. Deployments without the script keep working via the uncompressed fallback.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 64b510b789
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
WebServer::streamFile() already sets Content-Encoding: gzip for .gz files, so the explicit sendHeader produced a duplicated header.
Native Test Coverage
Report from native unit tests (ASan + gcov). |
Native Test Coverage
Report from native unit tests (ASan + gcov). |
Native Test Coverage
Report from native unit tests (ASan + gcov). |
serveWebFile() prefers the .gz variant, so uploading a newer plain asset through /api/fs/upload left the old .gz in place and the device kept serving the previous version. Uploading a plain asset now removes its sibling .gz; the compressed variant is re-created by the next upload of the .gz file. Addresses PR #184 review comment. Reviewed-by: Codex
On first activation of an admin tab the tab is shown before the potentially slow /api/config request completes, while all save buttons stay enabled — saving during that interval submits markup defaults for fields not yet populated. The config save buttons are now disabled while a config load is in flight and re-enabled when it finishes. Addresses PR #184 review comment. Reviewed-by: Codex
Native Test Coverage
Report from native unit tests (ASan + gcov). |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bdcbb29c9b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The native test mock only provides const char* overloads for LittleFS::exists() and remove(); passing the String directly broke the test build. c_str() works on both the mock and the real ESP32 API.
Native Test Coverage
Report from native unit tests (ASan + gcov). |
updateAuthUI() re-enables every Pool/Time tab control on each telemetry poll, which could undo setConfigSavesDisabled() while /api/config was still loading. The disabled state is now re-applied after the auth UI update whenever a config load is in flight. Addresses PR #184 review comment. Reviewed-by: Codex
A transient /api/config failure left the lazy-load guard set, so every later tab activation skipped loadConfig() while the finally block re-enabled the save buttons — leaving markup defaults editable without a loaded config. The guard is now only marked loaded on success; on failure it resets for a retry on the next activation and the save buttons stay disabled until a load succeeds. Addresses PR #184 review comment. Reviewed-by: Codex
Native Test Coverage
Report from native unit tests (ASan + gcov). |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 742cc5f75e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Disabling only the save buttons left every config input editable, so a late /api/config response unconditionally overwrote edits entered before the load finished. All fields, selects and buttons of the config tabs (pool, time, wifi, mqtt) plus the system password save are now disabled while a config load is in flight, and telemetry refreshes preserve that state. Addresses PR #184 review comment. Reviewed-by: Codex
Native Test Coverage
Report from native unit tests (ASan + gcov). |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b79e63ed62
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The lazy-load guard was set before loadSensors() ran, so a transient /api/sensors failure left it true and every later tab activation skipped loading — the user had to reload the whole page to recover. The guard is now only marked loaded on success; on failure it resets for a retry on the next tab activation and the error message gains an inline retry button. Addresses PR #184 review comment. Reviewed-by: Codex
Native Test Coverage
Report from native unit tests (ASan + gcov). |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ddc90e3737
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
After a transient /api/config failure configLoadsInFlight returns to zero while configLoaded is reset, so the next telemetry poll re-enabled every Pool/Time control — exposing markup defaults until the user switched tabs to trigger a retry. The disabled state is now re-applied while the config is unloaded (in flight or failed), not only while a request is in flight. Addresses PR #184 review comment. Reviewed-by: Codex
Native Test Coverage
Report from native unit tests (ASan + gcov). |
Problem
Opening the PWA shows a blank white page for several seconds before the dashboard appears, and every page open queues three data requests behind each other on the device server.
Root cause
The ESP32 serves every request through a single-threaded
WebServer, and startup was blocked by:style.css(16 KB) was render-blocking in<head>— the browser cannot paint anything until it arrives, and the dark theme only exists in CSS.app.js(55 KB) was parser-blocking at the end of<body>— HTML parsing halted until it downloaded and executed.loadConfig()andloadSensors()ran at page load — three requests queued behind the dashboard telemetry poll on the serialized server.Changes
data/web/sw.jscache: 'no-store'so freshly uploaded assets are picked up). API calls stay network-only. Cache bumped topool-ctrl-v2. App-shell fallback restricted to dashboard routes so/loginis never replaced by the shell.data/web/index.html/style.cssasynchronously (media="print"+onload,<noscript>fallback),app.jswithdefer.data/web/app.js/api/configand/api/sensorson first tab activation instead of at page load; telemetry starts immediately (deferred script, nowindow.onload). Config fields and save buttons stay disabled while config loads (re-applied across telemetry polls, also after a failed load) so saves can't submit markup defaults and late responses can't overwrite edits. Lazy-load guards reset on failure for automatic retry.src/WebPortal.cppCache-Controlheaders (no-cachefor entry page andsw.js,public, max-age=3600for static assets);serveWebFile()serves pre-compressed.gzvariants withContent-Encoding: gzip(uncompressed fallback); uploading a plain asset invalidates its stale.gzsibling.scripts/gzip-web-assets.shgzip -n -9); generated.gzfiles are gitignored.Effect
Verification
node --checkpasses forsw.jsandapp.js.pio run(norvi_ae01_r) SUCCESS./api/fs/uploadand verified live:Content-Encoding: gzipwith correct.gzsizes, lazy-load guards present,.gzinvalidation on plain upload.Deployment note
Web assets (
data/web/) deploy viapio run --target uploadfsor/api/fs/upload(runscripts/gzip-web-assets.shfirst to generate the.gzvariants; without them the firmware falls back to uncompressed). TheCache-Controlheaders and gzip serving require the firmware rebuild included in this PR.