I ran Lighthouse on my portfolio site and got a 72 on the homepage. Not terrible, but not great for a site that's mostly text and links. The about page was also 72. Blog listing was 95, blog posts 88, projects 85.
The thing is, I knew the site was fast subjectively. It loads instantly on my machine. But Lighthouse was flagging ~276KB of unused JavaScript, render-blocking resources, and a 7.4 second LCP on the homepage. Seven seconds for a page that's a heading and two paragraphs.
So I decided to fix it properly — not by adding a CDN or image optimization service, but by removing everything that shouldn't be there in the first place.
the audit
Before touching anything, I ran a full codebase analysis. The results were embarrassing in the best way — the kind of embarrassing where you realize the problem was never performance, it was accumulation.
Unused dependencies:
framer-motion(~150KB) — only imported by a carousel component I never shipped@floating-ui/react(~40KB) — same carousel@radix-ui/react-accordion(~30KB) — built the component, never used it@radix-ui/react-aspect-ratio(~6KB) — same story@uidotdev/usehooks(~50KB) — not imported anywhere in the entire codebase
That's ~276KB of JavaScript shipping to every visitor for literally zero functionality.
Unused components:
Five .tsx files that nothing imported — carousel, accordion, aspect-ratio, card, and a socials component. Dead code sitting in components/ui/ like furniture in a room nobody enters.
Unused assets:
- An old headshot I replaced months ago (24KB)
- Screenshots from a project I removed from the portfolio (511KB)
- Three SVGs from the Next.js starter template (
next.svg,vercel.svg,thirteen.svg) - A
markdown.cssfile (107 lines) that nothing imported
Bloated favicons: 26 favicon files totaling ~300KB. I needed 5.
the removals
The beauty of this cleanup is that phases 1-3 carry zero risk. You're deleting things nothing references. If the build passes, you're good.
Phase 1: removed all 5 unused deps from package.json. One bun install and the lockfile shed hundreds of lines.
Phase 2: deleted the 5 unused component files. No imports to update, no tests to fix.
Phase 3: deleted 7 unused static assets and the dead CSS file. Also nuked 21 favicon files, keeping only favicon.ico, favicon.svg, apple-touch-icon.png, one Android icon, and the manifest.
Total so far: 37 files deleted, ~1.1MB eliminated. Build still passes, all 14 tests green.
the interesting parts
The removals were the easy win. The interesting work was the component optimization.
CustomCursor: death by re-render
My custom cursor component was using React state to track mouse position:
const [position, setPosition] = useState({ x: 0, y: 0 })
useEffect(() => {
const handler = (e) => setPosition({ x: e.clientX, y: e.clientY })
window.addEventListener("mousemove", handler)
return () => window.removeEventListener("mousemove", handler)
}, [])Every mouse movement triggered setState, which triggered a React re-render, which diffed the virtual DOM, which updated the actual DOM. At 60fps. For a dot that follows your cursor.
The fix: useRef + vanilla JS. The ref holds the DOM element, and the mousemove handler updates style.transform directly. React renders exactly once (on mount) and never again.
Same visual result. Zero ongoing React overhead.
FilmGrain: the invisible GPU hog
The film grain overlay was a client component rendering a full-screen SVG with <feTurbulence> and a continuous animation. The browser was running an SVG filter computation on every animation frame across the entire viewport.
I replaced it with a server component that uses a CSS background-image with an inline SVG data URI. The grain texture is baked into the CSS — no JavaScript, no animation frames, no GPU filter pipeline. Just a static repeating texture with a CSS translate animation for the shifting effect.
The "use client" directive disappeared entirely. It's now a server component.
Blog listing: loading what you need
The blog listing page was importing allBlogs, which loads every post's full MDX content into memory just to display titles and dates. I added a allBlogMeta export that returns only the frontmatter — title, date, summary, slug. The listing page switched to that.
The individual blog post pages still load full content via getBlogBySlug(). Nothing changed for them.
the bug nobody saw
After all optimizations were done, I opened the site and saw... nothing. The sidebar was there, but the main content area was completely black. No text.
Turns out the stagger entrance animation was broken. Every content element starts with opacity: 0 and relies on a CSS animation called stagger-in to fade them in. The keyframes were defined in tailwind.config.js, but Tailwind wasn't emitting them in the CSS output.
Why nobody noticed: my system has prefers-reduced-motion: reduce enabled, and globals.css had a rule that forces opacity: 1 !important on .stagger-enter elements when reduced motion is active. The accessibility fallback was masking a broken animation.
The fix was simple — move the @keyframes stagger-in directly into globals.css instead of relying on Tailwind's config to generate them. Now the animation works for everyone, and the reduced-motion fallback still kicks in when appropriate.
This is the kind of bug that makes you appreciate defensive CSS. If that prefers-reduced-motion rule hadn't been there, the site would have been invisible to everyone, not just people without motion preferences.
the results
Lighthouse performance (desktop):
| Page | Before | After |
|---|---|---|
| Homepage | 72 | 100 |
| About | 72 | 99 |
| Blog listing | 95 | 98 |
| Blog post | 88 | 99 |
| Projects | 85 | 99 |
Core Web Vitals:
| Metric | Result | Threshold |
|---|---|---|
| LCP | 172-320ms | ≤2500ms |
| CLS | 0 | ≤0.1 |
| INP | 0ms | ≤200ms |
Every page passes every metric with 8x margin on LCP.
how to audit your own site
If you want to do this on your project, here's the process:
-
Get a baseline. Run Lighthouse on every distinct page type. Write down the scores. You can't improve what you don't measure.
-
Find unused dependencies. Search your codebase for each
package.jsondependency. If it's only imported by unused files, or not imported at all, it's dead weight. Tools like depcheck can automate this, but a manualgrepis more reliable. -
Find unused components. Search for each component's import. If nothing imports it, delete it. If you're scared,
githas your back. -
Find unused assets. Search for each filename in
public/. If no code references it, it's a free deletion. -
Check your client components. Every
"use client"directive is JavaScript that ships to the browser. Ask: does this need to be a client component? Can the interactive part be isolated? Can it use vanilla JS instead of React state? -
Verify after each change. Build, test, check. Don't batch all changes into one commit — if something breaks, you want to know which change caused it.
The whole process took me about two hours. Most of that was the audit. The actual changes were trivial.
what I learned
The biggest performance gains came from removing things, not optimizing them. The 276KB of unused JavaScript was doing more damage than any clever optimization could fix. The 37 deleted files mattered more than any config tweak.
Three takeaways:
- Audit before you optimize. Know what you're shipping. Know what's unused. The fastest code is code that doesn't exist.
- Client components have a cost. Every
"use client"is a commitment to ship JavaScript. Make sure it's worth it. - Test with
prefers-reduced-motiondisabled. Your accessibility fallbacks might be hiding real bugs.
If your Lighthouse score is lower than you'd expect, check your node_modules before reaching for a performance library. The answer might be subtraction, not addition.
