Skip to content

perf(landing): fix mobile LCP and cut the marketing pages from 6.3 MB to 0.6 MB - #419

Open
BSalaeddin wants to merge 3 commits into
devfrom
seo/mobile-performance
Open

perf(landing): fix mobile LCP and cut the marketing pages from 6.3 MB to 0.6 MB#419
BSalaeddin wants to merge 3 commits into
devfrom
seo/mobile-performance

Conversation

@BSalaeddin

Copy link
Copy Markdown
Collaborator

What and why

useupup.com scores 29 on PageSpeed Insights mobile (fleet SEO pull, 2026-09-11): LCP 17,687 ms, TBT 1,680 ms, TTI 26,398 ms, 143 requests, 6.3 MB. This PR makes the marketing pages paint their own copy before hydration and stops shipping four heavy, non-indexable surfaces to every visitor who never scrolls to them.

Measured locally (production next build + next start, Lighthouse 12.6.1, --preset=perf --form-factor=mobile --throttling-method=simulate, Playwright Chromium headless; median of 5 runs for /, 3 for the others). Raw JSON in upup-artifact/seo-2026-09-11/release/{lh-before-local,lh-after-local}/, full write-up in release/lh-summary.md.

/ (homepage)

Metric prod PSI (2026-09-11) local before local after change
Performance 29 56 74 +18
FCP 1,503 1,458 −45
LCP 17,687 9,588 2,161 −77%
TBT 1,680 722 1,090 +368 — see caveat
TTI 26,398 12,980 5,560 −57%
Speed Index 3,642 3,025 −617
CLS 0.002 0.003 ~0
Requests 143 133 30 −77%
Transferred 6.3 MB 6,037 KiB 591 KiB −90%
<link rel=preload as=image> in the HTML 11 11 0 −11
Server HTML 311 KiB 192 KiB −38%
DOM elements 1,707 1,670 801 −52%
Total main-thread work 12,680 ms 6,204 ms −51%

/react/ (representative framework page)

Metric local before local after change
Performance 57 71 +14
LCP 10,188 2,519 −75%
TBT 682 1,512 +830 — see caveat
TTI 13,781 5,718 −58%
Requests 139 36 −74%
Transferred 6,223 KiB 628 KiB −90%

(prod PSI reference for the sibling /vue/ page on 2026-09-11: performance 45, LCP 10,938.)

/docs/getting-started/ after: performance 73, LCP 2,359, TBT 1,235, 48 requests, 753 KiB. It has no local "before" — it is included as an untouched control page, see the TBT caveat.

LCP element, before and after

Same element both times — the hero subtitle — with completely different timing:

  • before: div.relative > div.grid > div.flex > p.text-base, LCP 9,588 ms, phases TTFB 4% / render delay 92%.
  • after: div.relative > div.grid > div.flex > p.hero-lift, LCP 2,161 ms, and the largest-contentful-paint-element audit now passes (score 1).

Root causes

  1. The hero copy was in the HTML but painted transparent. The whole left column was a motion.div variants={containerVariants} initial="hidden", and the H1 ran through BlurText, which wrapped every word in a motion.span with an inline opacity: 0 behind an IntersectionObserver. So the badge, H1, subtitle, CTAs and install command all shipped at opacity 0 and only became visible once framer had hydrated — 92% of a 9.6 s LCP was "render delay" on text that had been downloaded in the first 40 KB.
  2. Eleven decorative images were preloaded ahead of everything else. The hero's HeroSession scene renders eleven <img> tags with no width/height and no loading attribute; React 19 hoists those into <link rel="preload" as="image"> at the very top of the document, in front of the CSS the hero copy needs. curl -s https://useupup.com/ | grep -c 'rel="preload" as="image"' → 11; it is now 0.
  3. StackBlitz embedded on mount. sdk.embedProject ran in a mount effect on / and /react/, pulling ~4.2 MB of staticblitz client + monaco for a section most visitors never reach, and @stackblitz/sdk sat in the initial bundle.
  4. The live uploader demo was in the initial chunk set. InteractiveExampleClient dragged @useupup/interactive-example (+ @useupup/react, core, @mastra/client-js, pako) into / and every /[framework]/ page, for a section below the fold.
  5. Five more scenes rendered server-side in FeatureShowcase: more preloaded images, more DOM, more framer work, none of it indexable.
  6. Analytics competed with hydration. Hotjar and the GA gtag.js loader were both afterInteractive.

Changes

Hero (perf(landing): paint the hero copy from the server HTML…)

  • New CSS-only entrance in globals.css: .hero-rise (opacity + transform) and .hero-lift (transform only), staggered by an inline --hero-delay, both switched off under prefers-reduced-motion: reduce. The hero copy now animates off the server HTML instead of waiting for hydration.
  • The H1 and the subtitle use .hero-lift, never .hero-rise: an element at opacity: 0 has not painted, so fading the LCP text in delays LCP by the animation's delay and duration. Measured on this build: subtitle LCP went 3,687 → 2,161 ms from that one change alone.
  • BlurText.tsxRisingWords.tsx: same visible words, now plain text nodes with a per-word animation-delay. No framer, no IntersectionObserver, no will-change. (Its wrapper is also a <span> rather than the <p> it used to nest inside an <h1>.)
  • The hero visual (HeroSession) is next/dynamic({ ssr: false }) behind the useInView gate it already had, with a placeholder that reserves its measured box (min-h-[740px], max-w 440 — measured 735 px at a 412 px viewport, 743 px at desktop).
  • Every <img> in MockUploader / MockDriveBrowser / DragGhost gained intrinsic width/height (from a new sceneImageSize() manifest in scene-media.ts, read off the actual files) plus loading="lazy" decoding="async" — which is what stops React hoisting them into head preloads. They are object-cover in absolutely-positioned boxes, so the attributes change no layout.
  • whileHover/whileTap survive on the CTA buttons; nothing left has an initial that starts invisible.

Deferred surfaces (perf(landing): load the live demo, StackBlitz editor, feature scenes and analytics on demand)

  • New DeferredInteractiveExample.tsx: next/dynamic({ ssr: false }) behind a plain IntersectionObserver with rootMargin: 800px. Both / and /[framework]/ render it instead of InteractiveExampleClient. The lead distance is deliberate — the mounted demo is 1,564 px tall at a 412 px viewport against a 520 px placeholder, so the growth has to land off-screen.
  • StackBlitzDemoSection: embeds only when the editor card is within 300 px of the viewport (useInView({ once: true, margin: '300px' })), and @stackblitz/sdk is now a dynamic import() shared by the embed effect and the "Open in StackBlitz" button. Loading overlay, failure fallback, fullscreen re-embed flow and the #live-editor anchor are unchanged.
  • FeatureShowcase: the row text stays server-rendered (it is indexable); each Visual is next/dynamic({ ssr: false }) mounted by a second, earlier observer (margin: '600px', once) so the card's resize past its min-h-[320px] happens off-screen, while the existing amount: 0.2 gate still controls whether the scene animates.
  • Hotjar and the GA loader + its paired config script move to strategy="lazyOnload". PostHog init timing is untouched — the e2e ingestion specs depend on it.
  • apps/e2e-test/landing/thumbs-flow.spec.ts scrolls #demo into view before reaching for the Ask-AI panel, since the panel no longer exists at load.

CI (ci(landing): add a warn-only nightly mobile Lighthouse performance ratchet)

  • New apps/landing/lighthouserc.mobile.cjs: onlyCategories: ['performance'], mobile emulation, numberOfRuns: 5, aggregationMethod: 'median', port 4464 so it runs back to back with the existing config, reports to .lighthouseci-mobile-reports. lighthouserc.cjs is otherwise untouched apart from its header comment.
  • "lighthouse:mobile" script; one extra step in nightly's existing Lighthouse job; the report-upload step's path now covers both directories. Warn-level assertions only — the score is genuinely noisy on a shared runner, and continue-on-error is banned by scripts/ci/test-quality-guard.mjs, so a warn assertion is the only way to ratchet without risking a red night.
  • Floors are the measured median − 0.05: 0.66 for / + /react/ (medians 0.74 / 0.71), 0.68 for docs (median 0.73).
  • The two "perf is excluded as CI-runner noise" comments (nightly.yml, lighthouserc.cjs) and CLAUDE.md's CI section are updated in the same commits, per CLAUDE.md's rule about process changes.

No dependency changes (lockfile untouched), no copy changes, no route/metadata/canonical/JSON-LD/robots/sitemap changes, and no indexable text moved behind JS.

The TBT caveat

TBT is the one metric that moved the wrong way, and it is an artefact of the page no longer being network-starved:

  • Total main-thread work fell 51% (12,680 → 6,204 ms) and bootup-time fell with it. Before, that work was smeared across 13 s of waiting on 133 requests and 6 MB, so few individual tasks crossed the 50 ms threshold TBT measures. After, the same hydration happens in one burst inside a 5.5 s load — exactly what TBT is designed to punish.
  • The control page shows the floor is the machine. /docs/getting-started/ is untouched by this PR apart from the <Script> strategies, has no demo, no StackBlitz and no scenes, and still measures TBT 1,235 ms on this box. Per-run TBT on / spanned 731 → 1,705 ms (performance 81 → 70) across the five runs while other work competed for the CPU.
  • What is left is genuine hydration, not deferrable third parties: react-dom (1,217 ms of scripting, the single largest contributor) and posthog-js (87 KiB, 87% unused on this page).

Next piece of work, deliberately not in this PR: the homepage's client-component surface (HomepageFeatures is ~1,000 lines of 'use client' rendering indexable copy) and the PostHog bundle.

Verification done locally

Everything below was run from C:\wt-upup-perf on this branch.

Gate Command Result
Landing lint pnpm run lint (eslint . --max-warnings 0) PASS (exit 0)
Landing typecheck pnpm run typecheck PASS (exit 0)
Landing unit tests pnpm run test PASSTest Files 8 passed (8) / Tests 67 passed (67)
Test-suite hygiene pnpm run test:quality PASS399 test files + 5 workflows clean (0 exceptions pinned)
Retired vocabulary pnpm run vocab:check PASS1409 tracked files clean of 15 retired tokens
Formatting prettier --check on all 20 touched files PASSAll matched files use Prettier code style!
Docs e2e playwright test --config playwright.landing.config.ts --project docs PASS15 passed (10.2m), incl. playground CTA links into the homepage live editor (/#live-editor)
pre-commit hook ×3 commits PASS — core 146 passed, react 74 passed, server 32 passed / 6 skipped, each time
pre-push hook git push -u origin seo/mobile-performance PASS — repo-wide typecheck (31 tasks) + lint + knip, exit 0
Production build turbo run build --filter=@useupup/landing --force PASS

Behaviour proved in a real browser against a running server (Playwright Chromium, /):

  • .upup-ie-ai-panel count: 0 before scrolling, 1 after #demo.scrollIntoViewIfNeeded(), with the Ask-AI input visible — the path thumbs-flow.spec.ts now takes.
  • #live-editor iframe count: 0 before scrolling, 1 after — the StackBlitz gate embeds on approach, and the #live-editor anchor still resolves.
  • Served HTML on the production build: rel="preload" as="image" count 0 (was 11), hero subtitle and H1 words present as plain text, no opacity:0 anywhere in the hero.
  • Hero visual box measured at 735 px (412 px viewport) / 743 px (desktop) → placeholder set to 740 px; measured CLS stayed at 0.003.

Two unrelated suites flaked under load during this run and were confirmed green in isolation, per CLAUDE.md's flake protocol: @useupup/core tests/steps/heic-decode.test.ts and tests/strategies/multipart-part-body-materialization.test.ts (both use real 5 s/100 ms timing windows), and @useupup/interactive-example src/tests/InteractiveExample.test.tsx. All three passed in the commits' own hook runs.

What CI covers

  • main.yml — prettier, test-quality guard, all-package units + coverage ratchets, typecheck, build, size-limit, prod audit.
  • e2e.yml — affected-test routing will pick up the apps/landing + apps/e2e-test diff.
  • nightly.yml — the existing Lighthouse SEO/Best-Practices assertions are unchanged and still gate; the new mobile performance ratchet runs beside them at warn level, and its report lands in the nightly-lighthouse-reports artifact alongside the existing one.

Owner questions

  1. Is Hotjar still wanted? It is loaded on every page in production (site id 6368230) and is now lazyOnload. If nobody is reading the recordings it is free weight — say the word and it comes out entirely.
  2. Is GA still wanted alongside PostHog? Same question: gtag.js is a second analytics pipeline on every page, and PostHog already carries the product analytics.
  3. Cloudflare "Email Address Obfuscation" injects /cdn-cgi/scripts/.../email-decode.min.js into every page containing an email address — a request and a script we do not control and cannot defer from here. It is a dashboard toggle (Scrape Shield → Email Address Obfuscation) on the useupup.com zone. Worth turning off?
  4. Re-run PSI after this deploys so the prod numbers can replace the local ones in the ratchet's floors — the local box is shared and pessimistic.

Not merging this myself.

@codesandbox

codesandbox Bot commented Sep 12, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

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