Why build something slow on purpose?
Every performance article shows you the after. Almost none of them can show you the before, because the before
was someone's production incident. So I built the before myself: FinDash, a Next.js banking
dashboard with a marketing landing page and a 10,000-row transactions table, assembled from the greatest hits
of real-world slowness — 23.1MB of full-resolution PNGs with no dimensions, a render-blocking Google Fonts
stylesheet, three synchronous CDN scripts in the <head>, moment.js and lodash shipped to the
browser, and every one of the 10,000 rows mounted at once (~60,000 DOM nodes, re-rendered on every keystroke).
That starting point is preserved forever as the v0-baseline git tag, and it's deployed live next to the optimized version, so anyone can feel the difference — or paste
both URLs into PageSpeed Insights and
reproduce the numbers on Google's own infrastructure.
The rule for every change that followed: measure, change one thing, measure again, write down the delta. Each optimization is one commit with its numbers in the commit message. No vibes.
The headline numbers
Mobile Lighthouse, throttled, median of three runs, before and after measured back-to-back in the same session (cross-session Lighthouse numbers on a developer machine drift too much to compare honestly).
Landing — Lighthouse
52 → 88
LCP 14.1s → 3.3s (−77%), FCP 2.8s → 0.8s, Speed Index −82%.
Dashboard — Blocking time
11.6s → 0.7s
Total Blocking Time −94%. Search used to freeze the tab for seconds; it's now instant.
Image transfer
23.1MB → 216KB
−99% via next/image, AVIF/WebP, responsive sizes and below-the-fold lazy
loading.
PageSpeed (desktop, live URLs)
66 → 99
Landing score run by Google against the deployed site; dashboard went 44 → 94.
Ten passes, one lever each
The point of one-change-per-commit discipline is that you learn which lever moved which metric — and the answers were not the ones I would have guessed up front.
- Images.
next/imagewith static imports, AVIF/WebP,priorityon the hero. The single biggest LCP lever: 23.1MB → 216KB, landing LCP 13.7s → 5.7s. - Rendering strategy. The landing page became a statically generated Server Component; the dashboard's stat cards and chart series are computed at build time. Only the interactive table remains a client island.
- Bundle diet. moment.js replaced with hoisted
Intlformatters, lodash with native reductions — both dependencies deleted. My own component library gotsideEffects: falseso it finally tree-shakes. - Third-party scripts. The synchronous
<head>scripts moved tonext/scriptwithlazyOnload. Landing TBT 226ms → 83ms. - Fonts. Self-hosted subsetted woff2 via
next/fontwith a size-adjusted fallback. FCP 2.7s → 0.8s — first paint was waiting on fonts.googleapis.com the whole time. - Virtualization. TanStack Virtual mounts ~20 visible rows instead of 10,000. The biggest interaction lever by far: this one pass did more for TBT than everything else combined.
- Re-render hygiene.
useMemoon the 10k-row filter,useDeferredValueon search,memo()on the table. Load metrics unchanged (expected) — typing became visibly instant. - Data fetching. TanStack Query for dedupe and session cache; skeleton rows in the table's real layout instead of a spinner, so the page holds its shape while loading.
- Network & caching.
stale-while-revalidateheaders on the deterministic transactions API; hashed static assets served as immutable. - The budget. Lighthouse CI in GitHub Actions fails the build if the landing score drops below 85, LCP exceeds 4s, TBT exceeds 500ms, or script weight grows past budget. Every number above is one refactor away from regressing — unless a robot is watching.
The part nobody publishes: what made it worse
Two textbook optimizations measurably hurt, and both reverts are documented in commits with their numbers. This is why "measure after every change" includes the obviously good ones.
Negative result № 1
Code-splitting the charts
dynamic()-importing the dashboard charts looked like free bundle savings. Instead, the split
chunk landed in the shared chunk group — recharts started loading on the landing page — and its
delayed hydration forced a layout pass after the 10k-row table had already mounted, adding a multi-second
main-thread task. The rule that survived: split what loads on interaction; keep what renders
immediately.
Negative result № 2
Preloading the data payload
<link rel="preload" as="fetch"> on the 1.8MB transactions payload seemed like a head
start. On a throttled connection it competed with render-critical resources at HTML-parse time and regressed
mobile LCP to ~13s — nearly all the way back to the baseline. Preload is for small render-critical
resources, not bulk data.
What I'd tell you over coffee
The render-blocking chain beat the megabytes. Deleting 23MB of images moved LCP a lot — but first paint didn't budge until the font stylesheet and sync scripts left the critical path. FCP 2.8s → 0.8s came from the fonts and third-party passes, not the image pass everyone would bet on.
The DOM is the real bottleneck on interaction. No amount of memoization fixed the 10k-row table. With 60,000 mounted nodes, every browser operation is slow; virtualization (−94% TBT) was worth more than every other dashboard pass combined. Memoization is hygiene; it is not a rescue plan.
Provider placement is an architecture decision. A QueryClientProvider sitting
in the root layout cost the fully static landing page ~600ms of blocking time — for a provider that page never
uses. Moving it to a dashboard-only layout took the landing from 69 to 88. One line.
Honest tradeoffs stay in the README. The dashboard still downloads all 10k rows, because instant client-side search over full history is the feature being demonstrated — and the cost is an LCP asterisk, written down next to the durable fix (paginate the API) in future work. A case study that hides its asterisks is a scorecard, not engineering.
Feel the difference
Open the slow baseline first, then the optimized build. Then read the commit log — every number in this post is in a commit message or an archived Lighthouse report in the repo.