perf(web): cut landing page weight 928 KB → 336 KB, LCP 6.0s → 3.1s - #7
Merged
Conversation
Lighthouse mobile scored the landing page 68 with LCP at 6.0 s. Two causes, both on the critical path. Oversized raster assets. logo.png shipped 421x548 at 333 KB to be rendered 44 px tall (32 px on mobile) — 36% of total page weight for the header logo. icon.png shipped 512x512 at 86 KB and is fetched at High priority as the favicon. Resized to the largest size actually used (logo 180x234 covers the header at 3x and the README at 2x; icon 192x192 covers apple-touch-icon's 180 px) and quantized: 419 KB -> 14 KB, no visible difference. CodeMirror's mode bundle. codemirror-modes.min.js is ~190 KB containing every language mode, but the editor opens with mode: null and only needs it once a language is detected or picked. It now loads on demand through setCmMode(), the single choke point both call sites go through, carrying the same SRI hash the paste page applies. On failure the editor stays in plain text — syntax highlighting is cosmetic and must never block writing or submitting a paste. codemirror-core and e2e.js pick up defer while they are here. Measured locally with Lighthouse mobile, median of 3 runs each: before score 68 LCP 6.0s TBT 420ms 928 KB after score 94 LCP 3.1s TBT 11ms 336 KB CLS stays at 0. Verified in a real browser: the mode bundle is not requested on load, picking a language triggers exactly one fetch and highlighting activates, editor content survives the swap, and no CSP violation is raised — script-src allows 'self' and carries no 'strict-dynamic', so the injected same-origin script is permitted without a nonce. LCP is still above the 2.5 s target. What remains is codemirror-core (186 KB), which gates the LCP element because the editor area is empty until it runs. Fixing that means not initialising the editor on load, which is a UX decision rather than a mechanical optimisation.
This was referenced Jul 20, 2026
Merged
ClaraVnk
added a commit
that referenced
this pull request
Jul 20, 2026
Nothing has shipped since v1.5.0: production still serves that build, so the SEO, privacy, performance and accessibility work merged in #3 and #7-#10 is present on main and absent from ghostbit.dev. Pushes to main only publish the `edge` and `sha-*` image tags. The semver and `latest` tags — which is what a pinned deployment follows — are produced only by a v*.*.* git tag, and the last one predates all of it. Server changes in this release: - meta description, canonical link, /sitemap.xml, noindex on paste pages - star count fetched server-side; no third-party call from the browser and connect-src back to 'self' - landing page 928 KB -> 336 KB, LCP 6.0s -> 3.0s; paste page 515 KB -> 307 KB - form controls labelled, <main> landmark, light-mode contrast fixed - dead static assets removed, including a source map misnamed .sha256 Note the CLI has no changes since v1.5.0, yet this publishes ghostbit-cli 1.6.0 to PyPI with identical code: release.yml reads its version from cli/pyproject.toml, so a server-only release cannot be cut without it. Worth decoupling.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Contexte
Lighthouse mobile scored the landing page 68 with LCP at 6.0 s (reported in the field as 76 / 5358 ms). Reproduced locally, then fixed and re-measured against the same harness.
The LCP element is
pre.CodeMirror-placeholder— the editor area is empty markup until CodeMirror runs, so LCP is gated on JS. With FCP at 1.1 s and LCP at 6.0 s, the 5 s gap was almost entirely bandwidth on the critical path.Changements
Oversized raster assets — 419 KB → 14 KB
logo.pngicon.pnglogo.pngalone was 36% of total page weight for the header logo. New sizes cover every current use at ≥2x — the header at 3x, the README'swidth=90at 2x, and apple-touch-icon's 180 px. Both were visually compared before/after; the gradient shows no banding at these sizes.CodeMirror mode bundle off the critical path — 190 KB
codemirror-modes.min.jsbundles every language mode, but the editor initialises withmode: nulland only needs it once a language is detected or picked. It now loads on demand viasetCmMode(), the single choke point both call sites already went through.codemirror-coreande2e.jspick updeferwhile we are here.The injected script carries the same SRI hash the paste page applies to this bundle — it was previously loaded without SRI on the homepage, so this is a small hardening on the way past. On load failure the editor stays in plain text: highlighting is cosmetic and must never block writing or submitting a paste.
Tests
Lighthouse mobile, median of 3 runs each (single runs proved noisy — one showed 84 that three runs revealed as 93):
Browser-verified with puppeteer against the running app, since the lazy load is the real regression risk here:
No CSP violation because
script-srcallows'self'and carries no'strict-dynamic', so a same-origin script injected at runtime needs no nonce.Also: 115 tests pass (one added, guarding that the mode bundle never returns to a
<script>tag and that the injected URL keeps its SRI hash),ruff check/ruff format --checkclean.Risques
The lazy load is the change worth reviewing. Both paths that set a mode go through
setCmMode(), so there is no second call site to miss, and failure degrades to plain text rather than breaking the editor. The guard test exists because this is exactly the kind of win a future "just add the script tag" fix would silently undo.Rejected along the way: a server-rendered stand-in for the editor placeholder, so first paint would carry the LCP element. Measured over 3 runs it was not an improvement (median LCP 3177 ms vs 3134 ms without) — LCP still attached to CodeMirror's own placeholder. Dropped rather than kept as unmeasured complexity.
Follow-ups, not in this PR
codemirror-core(186 KB) gating the LCP element. Fixing it means not initialising the editor on page load — a UX decision, not a mechanical optimisation.marked+purify+highlight, none deferred. It is arguably the more visited page.footer.jscallsapi.github.comon every page load. A third-party request that costs a connection on mobile and discloses visitor IPs to GitHub — worth a cache or removal, and it is a privacy question as much as a perf one on a zero-knowledge service.