Starting from green
The first tree-shaking pass left two blocking checks running on every push: depcheck for unused dependencies and ts-prune for dead exports. Both were green going into 2.3.0. That changes what a second pass can even be about.
- No unused dependency to drop, no orphaned export to delete — the cheap, high-confidence removals were already gone.
- framer-motion already runs through
LazyMotion with the light m components, and every Three.js scene is behind a next/dynamic import with ssr: false, so the 3D stack never touches a first load. - So the only weight left is code that is imported and is used, but drags unused siblings along with it. That's not a deletion problem — it's a bundler-instruction problem.
The lever: barrels Next doesn't optimize by default
A barrel package re-exports everything through a single index.js. Import one member and the bundler often can't prove the rest is dead, so it keeps more than you used. Next.js fixes this by rewriting import { X } from "pkg" into a direct module import — but only for a hardcoded built-in list.
- Next's default list already covers the usual suspects here —
recharts, date-fns, lucide-react and friends — so those needed nothing. - What it does not cover: our own design-system package
@paul-portfolio/react (imported in shared UI, so it rides first-load paths), the two heavy barrels @react-three/drei and @unovis/react, and framer-motion. Each was confirmed a real barrel — single entry, many members — before being added. - The fix is one config block:
experimental.optimizePackageImports listing those four. No source changes, no import rewrites by hand. Total client JS went 13,468 KB → 13,320 KB — a real 148 KB, small on purpose, since the big wins were already taken.
The web-vitals check, and the one soft spot
A bundle number is a proxy. The real question was whether page speed is suffering, so this pass ran Lighthouse against the production build on the main routes under throttled mobile. Scores landed 82–94, CLS was effectively zero, blocking time was tiny, the server responded in 10ms. Nothing structurally broken — except one metric.
- LCP was the soft spot. Largest Contentful Paint sat in the "needs improvement" band on the heaviest pages — home 4.1s, operator 4.8s, pokemon 4.4s. Fast to interact, slow to paint the big element.
- The failing audit on every slow page was the same line: reduce unused JavaScript. No render-blocking stylesheet, no slow server, no unprioritized hero image. So there were two threads to pull: whatever was gating the paint, and whatever JS was genuinely unused.
What was actually gating the paint
I checked what the LCP element even was on each page. Same shape every time: a big block of text, sitting in the server-rendered HTML, but shipped with an inline opacity:0.
- Every page wraps its content in a framer-motion entrance —
initial="hidden", fade-and-rise on mount. Framer renders that hidden state into the SSR markup, so the largest content is painted but invisible. - It only becomes visible after the JS bundle downloads, React hydrates, and framer runs
animate="visible". On throttled mobile that whole chain is roughly four seconds — which is exactly where LCP landed, while first paint (FCP) was ~1.1s. The gap between them was the animation waiting on JS. - So this was never a "too much content" problem. The content was ready at FCP. A decorative entrance was deciding when it got to be seen.
The fix: paint first, animate second
The entrance is worth keeping — I just don't want it on the critical path. So I moved it from JS to CSS.
- A single
@keyframes reveal-up (fade + translateY) and a .reveal-up class in globals. It runs on the compositor the instant the element renders, with no bundle and no hydration in the way, so the content is visible at FCP and LCP lands with it. - Fill mode is
backwards on purpose: the from-state applies before the animation starts, but the element reverts to plain styles when it ends, so a later :hover transform isn't pinned by a forwards fill. Staggered groups just set an inline animation-delay per child, kept small so the largest element never waits long. - Reduced motion is handled by a
@media (prefers-reduced-motion: reduce) rule that switches the animation off — no JS hook, honoured before a single script runs.
Trimming the JS that really was unused
The other thread was the reduce unused JavaScript audit. Operator had a clean, real win. Pokemon taught me to read the audit more carefully.
- Operator: lazy charts. The Fleet Analytics section pulls in
recharts (~66 KB) and defaults to collapsed, so that whole library was 100% unused on load. I loaded the three chart components through next/dynamic, so recharts only downloads when someone actually opens the section. Unused JS 118 KiB → 70 KiB, and this one moved the lab number too — operator LCP 4.8s → 4.2s. - Pokemon: it was prefetch, not weight. Its flagged chunk is zod, but the pokemon hub never imports zod. The hub links to the GraphQL and TCG Pocket pages, and Next prefetches those routes — they use zod, so their chunk rides along. That's a navigation speedup, not page weight. Killing it would mean turning off prefetch, which is a worse trade. So I left it, and this is me writing down why.
The number that lied: lab vs field
Here's the twist. After the CSS-reveal fix, Lighthouse's LCP barely budged — home still read ~4.7s. If that were the only number I looked at, I'd have called the fix a failure and reverted it.
- So I measured it a second way: real headless Chrome, real 4× CPU and Slow-4G throttling, reading the actual
largest-contentful-paint entry. That's the field, not a model. - Real-user LCP, before → after (throttled Chrome):
- home4284 ms→1712 ms−2.5s
- operator3228 ms→1320 ms−1.9s
- pokemon2640 ms→1472 ms−1.2s
- The lab tool and the field disagreed hard, and the field was right. Lighthouse's default score is a simulation: it loads the page quickly, then estimates slow-mobile timings from a model of the JS dependency graph. That model is sharp for JS-bound delays and effectively blind to a compositor animation that paints early — so it kept crediting the old JS-graph timing that no longer described reality.
- The lesson I'm keeping: a lab metric is a proxy, and a proxy can be wrong. When it disagrees with what a real throttled browser paints, trust the browser. I'd have thrown away the best fix in this whole pass if I'd stopped at the Lighthouse column.
Applying it everywhere it fit (and where it didn't)
Home, operator and pokemon were where I started because they measured worst. But the initial="hidden" entrance was all over the app, so the same fix applied anywhere a page gates its above-the-fold content on mount.
- Converted: the landing and signed-in hub (both the slot machine), operator, pokemon, the flags console, the store-detail page, every design-system section, and the learn hero — each one was shipping its largest text at
opacity:0 until hydration. - Left alone on purpose: the scroll-triggered reveals (they fire on
whileInView, they're below the fold, and turning them into mount animations would make off-screen content animate to nobody), the interactive animations like the slot spin, and the retired v1/v2 pages. The fix only belongs where JS was gating the first paint — not on every animation in the codebase.
The takeaway
- When the delete checks are already green, the next win isn't a bigger delete — it's telling the bundler to ship less of the code you kept, and not shipping the code you kept as
opacity:0 until hydration. - An entrance animation should never decide when your content becomes visible. Paint first, animate second, and do the animation in CSS so it never rides the JS bundle.
- Measure in the thing users actually feel. The lab number said the LCP fix did nothing while a real throttled browser painted 2.5 seconds sooner. When the proxy and the field disagree, the field wins — and it's worth building the second measurement so you can tell.
What I'd do now, and what I did
- A second pass that started from green rather than assuming the first had held, which is the only honest way to run one.
- The distinction between bundle size and what was actually gating the paint — the two are related and not the same, and only one of them was the problem.
- Two of the barrels on that optimize list deleted rather than optimized. @unovis drew five sparklines on /vitals and charged a D3 constellation plus leaflet, supercluster and topojson for it; recharts already drew eighteen other charts here, so porting cost no new library and took the whole tree out of the lockfile.
- gsap went the same way, for two components. What it was doing — opacity and transform tweens on DOM nodes — is what the Web Animations API is for, so the replacement is native and weighs nothing. back.out(1.7) is a cubic-bezier, a centre-out stagger is arithmetic on the index, and clearProps is just declining to fill forwards.
- The honest version of the trade: the /vitals chunk did not shrink, because recharts is the heavier of the two per chart. The install shed the whole dependency, and there is one charting paradigm to maintain instead of two.
Where it could go further
- Still no budget in CI, so a third pass will be needed for the same reason as the second.
- The soft spot the web-vitals check surfaced is documented rather than resolved.
- Nothing stops the next second-library-for-one-component. A rule that a new dependency has to earn more than one consumer would have caught both of these at the point they were added.
Next on this
- A size budget, which both of these write-ups have now independently concluded is the missing piece.