This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
A systematic pass through each Core Web Vital — what was wrong, why, and what changed.
Google measures web performance with five field metrics collected from real users — not lab simulations. Each has a threshold: good, needs improvement, or poor. The P75 value (75th percentile across all visits) is what counts.
LCP — Largest Contentful Paint: how fast the main content loadsFCP — First Contentful Paint: how fast something first appears on screenCLS — Cumulative Layout Shift: how much content jumps around during loadINP — Interaction to Next Paint: how fast the page responds to clicks and tapsTTFB — Time to First Byte: how fast the server starts sending a responseThe biggest quick win. Every user with dark mode saved had a visible flash from light to dark on every page load.
The root cause: ThemeProvider sets data-theme on <html> via useEffect. That runs after the first paint. The browser already rendered the page with the light theme CSS applied before the dark theme switch happened.
The fix is a blocking inline script in <head> that reads localStorage and sets data-theme synchronously before CSS parses. The script is a self-contained IIFE wrapped in try/catch so it silently does nothing in environments where localStorage is unavailable (private browsing, SSR).
useEffect in ThemeProvider still runs but it's now a no-op — the attribute is already set to the right valueWithout an explicit revalidation strategy, Next.js re-renders pages on every request. For pages with no dynamic data, that means the origin server rebuilds HTML that will be identical to the last build — wasted work on every request.
The fix is export const revalidate at the page level. Next.js caches the rendered HTML at the CDN edge and serves it from there, only rebuilding when the interval expires.
revalidate = 86400 — static write-upsrevalidate = 3600 — the page shell is static, client fetches live ESPN data on mountThe TCG and GraphQL proxy routes already had Cache-Control headers. The geo route was the only public API route missing them. A geolocation result for a given IP is stable for a session — no reason to hit the upstream service on every weather canvas load.
Added private, max-age=300 — private so CDNs don't share geo results between users, five minutes so the browser reuses the response within a session.
The landing page (unauthenticated view) imported all eleven sections eagerly — HeroSection, FeaturesSection, AuthSection, and eight more. They all landed in the same initial JavaScript chunk and hydrated together.
The LCP element is the H1 in HeroSection. The browser has to download, parse, and execute the full landing bundle before it can evaluate LCP candidates. Eleven sections worth of Framer Motion variants, canvas effects, and animated previews all competing for that first frame.
The fix: HeroSection stays eager. The other ten are wrapped in next/dynamic with ssr: true. Their HTML is still included in the server-rendered stream for SEO, but their JavaScript loads in separate async chunks after the initial bundle. The hero renders and LCP fires before those chunks even begin downloading.
The weather canvas ran at 60fps on the main thread — Phong rain simulation, 260-particle snow, animated fog bands. Any click or tap had to wait for the current canvas frame to finish before the browser could respond. That wait is INP.
Two changes landed without moving work off-thread:
timestamp - lastFrameTime against a 33ms budget and skips frames that arrive too early. Halves main-thread load with no visible quality drop for a weather effect.scheduler.isInputPending yield — when the browser has queued pointer or keyboard events, the loop skips that frame entirely. The browser dispatches the event immediately instead of queuing it behind canvas work. This is a non-standard API (Chrome only) so it degrades gracefully where absent.The authenticated hub fetched the user's name and email from /api/me on the client with no initial data. The header showed skeleton bones until the fetch resolved — a skeleton-to-content swap visible on every authenticated page load.
The session was already available in page.tsx — auth0.getSession() runs there to decide whether to render the hub or the landing page. The name and email were already in hand.
The fix: extract them from the session and pass them as initialMe to FeatureHub, which seeds the TanStack Query cache via initialData. The query still runs in the background and refreshes after 5 minutes. The user name renders on first paint — no skeleton.
The Pokémon browser syncs search filters to the URL via a useEffect that calls router.replace. When the user clicked a card to navigate away, router.replace fired concurrently with the Link navigation and canceled it — the card detail page never loaded.
Fixed by guarding the effect with usePathname. If the current path is no longer /tcg/pokemon, the effect returns early and lets the navigation complete.
Update — September 2, 2026
The vitals dashboard had gone red again — the by-page table showed the home LCP up around ten seconds and half the thoughts pages in the Poor band. I nearly took it at face value and started tearing pages apart. I didn't, and that turned out to matter twice.
Before touching a page I ran Lighthouse against a production build under mobile throttling, which is the closest a lab gets to the slow real devices behind a P75. First contentful paint came back around a second everywhere. The problem wasn't that nothing painted — it was that the largest thing painted seconds later.
/ (home, mobile, throttled) FCP 1.1s LCP 6.3s TBT 190ms
the 5s gap is one element: the hero <h1>The hero heading animates in with a class called .reveal-up, and I'd left myself a confident note next to it: entrances above the fold are CSS and never framer, so the headline is painted on the first frame either way. That reasoning is half right. Framer ships the element as opacity: 0 and waits on hydration, which CSS does avoid — but the keyframe itself also starts at opacity: 0, and Chrome doesn't count an element as painted until it's visible. CSS versus framer was never the axis that mattered. Opacity was. The largest text on the page sat invisible until the fade ran, and under a busy main thread the fade ran late.
The fix is a sibling class, .rise-in — the same twenty-pixel slide, on transform alone, opacity pinned at 1 the whole way. An element offset by a translate is still painted; only its position moves. So the heading counts as visible on the first frame and LCP stops waiting. I used it only for the above-the-fold hero text; .reveal-up still fades everything below the fold, where the LCP clock has already stopped.
home LCP, mobile throttled, median of 6 runs before 6.3s (heading waiting to fade in) after 3.2s (heading painted on frame 1)
I'm not going to call 3.2s good — it's still short of the 2.5s line. But the remaining time isn't the entrance any more, it's client-JS bootup around the 3D hero, which is a different job with a bigger blast radius. The entrance was the cheap, certain three seconds, and it's a fix I can pin: a test asserts the heading uses .rise-in and that .rise-in never touches opacity, so nobody re-gates it by reflex.
The reason I didn't go page-smashing: most of the red cells were on pages with seven to thirty samples, and a P75 over a handful of samples is two slow phones and a guess. /research was the giveaway — a CLS of 0.716 sitting next to LCP/FCP/INP that read perfectly fine, off a sample count too small to trust. That's not a layout bug; it's an untrustworthy number wearing a real one's clothes. The floor on a cell's sample count lived per page, not per metric, so a page could clear it while one metric had three samples. The fix for that is in the API, not here: a per-metric sample floor, so a cell only shows once enough data backs it. The lesson for this page is the older one — measure the true signal before you believe the convenient one.
Update — September 3, 2026
The entrance fix halved the home LCP but left it at ~3.2s in the lab, short of the 2.5s line. The obvious next move was the 161 KiB of unused JavaScript the audit kept flagging on the hero. I almost spent a day on it. Measuring first is the only reason I didn't.
The two heavy chunks are three.js and its React renderer — already lazy-loaded behind an idle callback and a WebGL check, so they arrive late and on their own. I blocked them outright in Lighthouse to see what they cost the paint. The answer was nothing:
home LCP, mobile throttled with three.js 3224 ms TBT 107 ms three.js blocked 3222 ms TBT 33 ms <- LCP unchanged
Its long tasks land at 6.6s, well after the 3.2s LCP. Trimming it would buy back some TBT and bandwidth, but not one millisecond of LCP. The premise was wrong.
The LCP element is the hero tagline — stable server-rendered text, no hydration swap — and its observed render delay is 94ms. The 3.2s is lantern's model of the throttled critical path, not a defect I can point at. Real-user LCP tracks the ~1.2s the text actually paints at. The entrance fix was the whole win; there was no second three seconds to find.
What the profiling did surface was an architecture smell. The landing's top component was "use client" and did nothing but forward props, and under it seven sections with no state at all hydrated in full — shipping ~843 lines of static content data to the browser for markup that never changes. So the sections are server components again, and only the interactive leaves stay client: the session control, the scroll bar, the 3D scene, and one new RevealOnScroll island that replaced the same scroll-reveal copy-pasted into two sections.
First Load JS for / 792 KiB -> 770 KiB craft-trait data in a client chunk -> server HTML only
This does not move LCP either, and I shipped it anyway — not to chase the number, but because the correct boundary is better engineering and the data belongs on the server. The honest lesson repeats the one from the entrance fix: measure the true signal before you spend a day on the convenient one.