Skip to content

perf: speed up mobile LCP on trading-view token & pair pages - #1288

Merged
miohtama merged 1 commit into
masterfrom
perf/mobile-lcp-trading-view
Jun 25, 2026
Merged

perf: speed up mobile LCP on trading-view token & pair pages#1288
miohtama merged 1 commit into
masterfrom
perf/mobile-lcp-trading-view

Conversation

@miohtama

Copy link
Copy Markdown
Contributor

Why

Google Search Console flagged a mobile Core Web Vitals issue — LCP longer than 2.5s — affecting 411 URLs, with the worst offenders being trading-view token detail pages (/trading-view/[chain]/tokens/[address]) and trading-pair pages (/trading-view/[chain]/[exchange]/[pair]), measured at 2.6–3.4s.

Investigation showed the LCP element on these pages is the <h1> page heading (text), not the chart. A third affected URL group is a static /docs/ page with no SvelteKit and no chart, yet equally slow — which points the diagnosis at factors shared across all pages: server response time (TTFB), edge-cache reuse, and render-blocking web fonts, rather than the chart/JS bundle.

Lessons learnt

  • LCP on these pages is text, gated by TTFB + render-blocking resources — not the chart. The largest element is the heading; the chart is below the fold. Optimise the path to first text paint.
  • font-display: swap means font preloading does not move the LCP timestamp. With swap, the heading paints immediately in a fallback font; the web-font swap is a later repaint. An earlier attempt to preload specific weights was dropped — it added fragile, design-token-coupled file references and per-page "preloaded but not used" warnings for zero LCP gain. The real font lever is that the font stylesheet was render-blocking; making it non-blocking is what helps.
  • fonts5.css is purely @font-face, so it can safely load non-render-blocking (media="print"onload) without affecting element geometry.
  • The token loader had a needless SSR waterfall: token/details and the Typesense reserves search were awaited sequentially, but the reserve search keys off the URL address, not the token response — they are independent and can run in parallel.
  • The long tail kills edge-cache reuse. With 411 rarely-hit URLs, a plain max-age mostly misses the cache → cold SSR per visit. stale-while-revalidate serves a cached copy instantly and refreshes in the background.
  • CSP here only enforces frame-ancestors: self (no script-src), so the inline onload non-blocking-CSS pattern is permitted; a <noscript> fallback covers JS-off.

Summary

  • tokens/[token]/+page.ts — fetch token and reserves in parallel via Promise.all instead of a sequential waterfall, so SSR (and the LCP heading) is gated by the slower call, not the sum.
  • Both +page.ts loaders (token + pair) — add stale-while-revalidate=86400 to the cache header so the Cloudflare edge serves the long tail of URLs instantly while refreshing in the background.
  • [exchange]/[pair]/+page.svelte — lazy-load the below-the-fold PairCandleChart via {#await import(...)}, moving lightweight-charts off the page's static critical path (verified split into a dynamically-imported client chunk).
  • app.html — load fonts5.css non-render-blocking (media="print"onload="this.media='all'") with a <noscript> fallback, removing a render-blocking resource from first paint.
  • PageHeader.svelte — consolidate the heading to a single weight: dropped the .multiline font-weight: 700 override so all page headers use their --f-h1-medium (600) design token, instead of mixing 600 and 700.

🤖 Generated with Claude Code

Reduce mobile Largest Contentful Paint on the trading-view token and
trading-pair detail pages (top offenders in Search Console Core Web
Vitals) by cutting server response time, improving edge cache reuse,
trimming the critical path, and unblocking font loading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@miohtama
miohtama merged commit aa13ee8 into master Jun 25, 2026
8 of 10 checks passed
miohtama added a commit that referenced this pull request Jun 25, 2026
## Why

PR #1288's CI repeatedly failed on 3 tests in `tests/integration/trading-view/vaults/datasets.test.ts` (the vault-datasets API-key flow) — a different overlapping subset each run — while they pass reliably locally. This is long-standing: the CI workflow itself notes (since 2023) that the integration suite is *"too flaky and do not pass on CI at all"*, and `build`'s `needs: test` is commented out because of it.

**Root cause (reproduced, not guessed):** the integration suite drives the whole app against a single-process `vite preview` server whose APIs are served by an in-process mock middleware (`vite-plugin-mock-dev-server`). Under the parallel-worker load CI applies, that shared server intermittently mishandles a request. The captured failure snapshot shows the form displaying **"The API key is not valid"** after submitting the *valid* key — i.e. the mock returned 401 for a correct key under contention. It's environmental, not an app bug.

Reproduced locally by running the file under `--workers=4 --repeat-each=8`: **7/176 runs failed** with the exact CI symptom. Re-running the identical stress with `--retries=2`: **0 hard failures** (all flakes self-recovered).

The integration/e2e Playwright configs had **no `retries` set**, so CI ran with 0 retries — any single transient blip failed the whole job.

## Lessons learnt

- **The integration harness shares one preview+mock process across all Playwright workers.** Parallel load causes occasional request mishandling (here: a valid API key mock-rejected with 401). Treat integration/e2e flakiness on CI as environmental contention first; reproduce with `--workers=N --repeat-each=M` before suspecting app code.
- **Retries are the standard mitigation for inherently flaky server-backed e2e/integration suites** — but only on CI. Keep retries at 0 locally so genuine regressions surface immediately instead of being masked.
- The failure surfaces as either a 5s assertion timeout *or* a spurious "API key not valid" error — both trace back to the same shared-server contention, so a deterministic `waitForResponse` in the test would not have fixed the 401 case; retrying does.
- If the flakiness needs eliminating at the source later, the lever is reducing contention (e.g. fewer integration workers) or a more concurrency-robust mock, not the app.

## Summary

- Added a shared `ciRetries` constant in `tests/helpers.ts` (`process.env.CI ? 2 : 0`) with a comment documenting the root cause.
- Wired `retries: ciRetries` into all three Playwright configs: `tests/integration/playwright.config.ts`, `tests/integration/private.playwright.config.ts`, and `tests/e2e/playwright.config.ts`.
- No app/source changes — test-infrastructure only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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