Skip to content

perf(web): cut landing page weight 928 KB → 336 KB, LCP 6.0s → 3.1s - #7

Merged
ClaraVnk merged 1 commit into
mainfrom
perf/landing-page-weight
Jul 20, 2026
Merged

perf(web): cut landing page weight 928 KB → 336 KB, LCP 6.0s → 3.1s#7
ClaraVnk merged 1 commit into
mainfrom
perf/landing-page-weight

Conversation

@ClaraVnk

Copy link
Copy Markdown
Contributor

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

asset before after rendered at
logo.png 421×548, 333 KB 180×234, 10 KB 44 px tall (32 px mobile)
icon.png 512×512, 86 KB 192×192, 4 KB favicon + apple-touch-icon (180 px)

logo.png alone was 36% of total page weight for the header logo. New sizes cover every current use at ≥2x — the header at 3x, the README's width=90 at 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.js bundles every language mode, but the editor initialises with mode: null and only needs it once a language is detected or picked. It now loads on demand via setCmMode(), the single choke point both call sites already went through. codemirror-core and e2e.js pick up defer while 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):

score FCP LCP TBT CLS weight
before 68 1.1 s 6.0 s 420 ms 0 928 KB
after 94 1.1 s 3.1 s 11 ms 0 336 KB

Browser-verified with puppeteer against the running app, since the lazy load is the real regression risk here:

modes bundle fetched on initial load: 0 (expect 0)
editor initialised: true
syntax highlighting active after language pick: true
modes bundle fetched after pick: 1 (expect 1)
editor content preserved: true
CSP violations / page errors: none

No CSP violation because script-src allows '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 --check clean.

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

  • LCP is still above the 2.5 s target. What remains is 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.
  • The paste page has the same profile, unaddressed here: it loads CodeMirror core + modes + marked + purify + highlight, none deferred. It is arguably the more visited page.
  • footer.js calls api.github.com on 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.

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.
@ClaraVnk
ClaraVnk merged commit 5c88c61 into main Jul 20, 2026
3 checks passed
@ClaraVnk
ClaraVnk deleted the perf/landing-page-weight branch July 20, 2026 10:30
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.
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