Performance · Next.js · Core Web Vitals

Slow on Purpose, Fast on Evidence

I built a banking dashboard to be deliberately slow — 23MB of images, 60,000 DOM nodes — then made it fast in ten measured passes. Two "optimizations" made it slower. This is the paper trail.

~8 min read Live demo Slow baseline Source on GitHub

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.

  1. Images. next/image with static imports, AVIF/WebP, priority on the hero. The single biggest LCP lever: 23.1MB → 216KB, landing LCP 13.7s → 5.7s.
  2. 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.
  3. Bundle diet. moment.js replaced with hoisted Intl formatters, lodash with native reductions — both dependencies deleted. My own component library got sideEffects: false so it finally tree-shakes.
  4. Third-party scripts. The synchronous <head> scripts moved to next/script with lazyOnload. Landing TBT 226ms → 83ms.
  5. Fonts. Self-hosted subsetted woff2 via next/font with a size-adjusted fallback. FCP 2.7s → 0.8s — first paint was waiting on fonts.googleapis.com the whole time.
  6. 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.
  7. Re-render hygiene. useMemo on the 10k-row filter, useDeferredValue on search, memo() on the table. Load metrics unchanged (expected) — typing became visibly instant.
  8. 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.
  9. Network & caching. stale-while-revalidate headers on the deterministic transactions API; hashed static assets served as immutable.
  10. 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.