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.