Skip to content

perf(web): optimize PWA startup - #184

Merged
stritti merged 18 commits into
mainfrom
feat/pwa-startup-optimization
Aug 18, 2026
Merged

stritti merged 18 commits into
mainfrom
feat/pwa-startup-optimization

Conversation

@stritti

@stritti stritti commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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:

  1. 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.
  2. app.js (55 KB) was parser-blocking at the end of <body> — HTML parsing halted until it downloaded and executed.
  3. The service worker used network-first — even repeat visits with a warm cache waited for the network round-trip before showing anything.
  4. Assets were served uncompressed — a first visit transferred ~112 KB.
  5. loadConfig() and loadSensors() ran at page load — three requests queued behind the dashboard telemetry poll on the serialized server.

Changes

File Change
data/web/sw.js Stale-while-revalidate v2: repeat visits render instantly from cache while assets refresh in the background (cache: 'no-store' so freshly uploaded assets are picked up). API calls stay network-only. Cache bumped to pool-ctrl-v2. App-shell fallback restricted to dashboard routes so /login is never replaced by the shell.
data/web/index.html Inline critical first-paint styles, load /style.css asynchronously (media="print" + onload, <noscript> fallback), app.js with defer.
data/web/app.js Lazy-load /api/config and /api/sensors on first tab activation instead of at page load; telemetry starts immediately (deferred script, no window.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.cpp Cache-Control headers (no-cache for entry page and sw.js, public, max-age=3600 for static assets); serveWebFile() serves pre-compressed .gz variants with Content-Encoding: gzip (uncompressed fallback); uploading a plain asset invalidates its stale .gz sibling.
scripts/gzip-web-assets.sh Pre-compress web assets (gzip -n -9); generated .gz files are gitignored.

Effect

  • First visit: paints the themed page after the HTML alone (33 KB) instead of waiting for HTML + CSS + JS (104 KB) through the serialized server; transfer drops to ~28 KB with gzip.
  • Repeat visits: render instantly from the service worker cache, refreshed in the background.
  • Page open: only the dashboard telemetry request fires; config/sensor data loads when their tabs are opened.
  • Offline: cached app shell or offline page, as before.

Verification

  • node --check passes for sw.js and app.js.
  • Firmware builds: pio run (norvi_ae01_r) SUCCESS.
  • Native test suite: 114 suites / 209 assertions passed.
  • All CI checks green (MegaLinter, 3× build, test-and-coverage, Analyze, CodeQL).
  • Deployed to the device via OTA + /api/fs/upload and verified live: Content-Encoding: gzip with correct .gz sizes, lazy-load guards present, .gz invalidation on plain upload.

Deployment note

Web assets (data/web/) deploy via pio run --target uploadfs or /api/fs/upload (run scripts/gzip-web-assets.sh first to generate the .gz variants; without them the firmware falls back to uncompressed). The Cache-Control headers and gzip serving require the firmware rebuild included in this PR.

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.
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

Report from native unit tests (ASan + gcov).

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

⚠️MegaLinter analysis: Success with warnings

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
⚠️ MARKDOWN 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

⚠️ Your configuration references items that have been removed from MegaLinter and are ignored: 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

MegaLinter is provided by OX Security
Show us your support by starring ⭐ the repository

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread data/web/sw.js Outdated
Comment thread data/web/sw.js
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
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage —%
Branch Coverage —%
Lines Hit/Total 0/0
Branches Hit/Total 0/0

Report from native unit tests (ASan + gcov).

Fixes MegaLinter editorconfig-checker failure on PR #184.

Reviewed-by: Codex
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

Report from native unit tests (ASan + gcov).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread data/web/sw.js
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
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/WebPortal.cpp
Comment thread data/web/app.js
WebServer::streamFile() already sets Content-Encoding: gzip for .gz
files, so the explicit sendHeader produced a duplicated header.
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

Report from native unit tests (ASan + gcov).

@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

Report from native unit tests (ASan + gcov).

@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

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
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage —%
Branch Coverage —%
Lines Hit/Total 0/0
Branches Hit/Total 0/0

Report from native unit tests (ASan + gcov).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread data/web/app.js
Comment thread data/web/app.js Outdated
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.
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

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
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

Report from native unit tests (ASan + gcov).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread data/web/app.js Outdated
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
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

Report from native unit tests (ASan + gcov).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread data/web/app.js
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
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

Report from native unit tests (ASan + gcov).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread data/web/app.js Outdated
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
@github-actions

Copy link
Copy Markdown
Contributor

Native Test Coverage

Metric Value
Line Coverage 44.5%
Branch Coverage 73.2%
Lines Hit/Total 335/752
Branches Hit/Total 156/213

Report from native unit tests (ASan + gcov).

@stritti stritti changed the title fix(web): eliminate white screen on PWA startup perf(web): optimize PWA startup Aug 18, 2026
@stritti
stritti merged commit 3d61ebd into main Aug 18, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant