1.1MB Lighter: A Performance Cleanup That Actually Mattered
Back to blog

1.1MB Lighter: A Performance Cleanup That Actually Mattered

·Dan Castrillo

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.css file (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):

PageBeforeAfter
Homepage72100
About7299
Blog listing9598
Blog post8899
Projects8599

Core Web Vitals:

MetricResultThreshold
LCP172-320ms≤2500ms
CLS0≤0.1
INP0ms≤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:

  1. Get a baseline. Run Lighthouse on every distinct page type. Write down the scores. You can't improve what you don't measure.

  2. Find unused dependencies. Search your codebase for each package.json dependency. 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 manual grep is more reliable.

  3. Find unused components. Search for each component's import. If nothing imports it, delete it. If you're scared, git has your back.

  4. Find unused assets. Search for each filename in public/. If no code references it, it's a free deletion.

  5. 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?

  6. 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-motion disabled. 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.

Related Posts