Skip to content
← Back to blog
7 min read

My portfolio was shipping 26MB and I had no idea

performance
nextjs
web

I never actually tested how fast my own portfolio loads. It looked fine on my machine, Lighthouse gave it a good score, so I assumed it was fine and moved on.

Then I actually loaded it in a throttled browser and recorded every request.

26.67 MB. Thirty requests, before scrolling anywhere.

What was in it

Almost all of it was one section nobody sees unless they scroll to the bottom.

assetsizewhat it is
dance.glb13.42 MBa decorative 3D avatar
decoded HDR9.78 MBlighting data, in memory
venice_sunset_1k.hdr1.33 MBfetched from GitHub at runtime
three.js0.65 MBthe 3D engine
drei0.27 MBthree.js helpers

The 3D model sits in my contact section, at the very bottom of the page. Every visitor was downloading it, including the ones who bounced from the hero.

Why it loaded immediately

I had written this and assumed it meant "load this later":

const DancingPersonCanvas = dynamic(() => import('@/components/dance'), {
  ssr: false,
})

It doesn't. ssr: false means "don't render this on the server." That's all. The component was still sitting in the tree unconditionally, so React mounted it the moment hydration reached it, about a second after load, while you're still looking at the top of the page.

Deferring is a separate mechanism. The Next.js lazy-loading docs spell this out: ssr: false is annotated "Load only on the client side", while {condition && <Component />} is "Load on demand, only when/if the condition is met." I had used the first and assumed it did the second.

There was a second trigger I'd missed too. At the bottom of the 3D component:

useGLTF.preload('/dance.glb')

Module scope. That runs when the file is parsed, not when the component mounts. Even if I'd fixed the gating, that line would have kept firing the 13 MB download.

The fix

An IntersectionObserver that gates the mount, with a margin so the model starts loading just before it scrolls into view:

const observer = new IntersectionObserver(
  ([entry]) => {
    setIsOnScreen(entry.isIntersecting)
    if (entry.isIntersecting) setHasEnteredView(true)
  },
  { rootMargin: '300px 0px' }
)

Two pieces of state, doing different jobs. hasEnteredView latches once and controls whether the thing mounts at all. isOnScreen keeps tracking in both directions, so rendering can stop when you scroll past. The animation loops forever, and it was running on the GPU whether or not anyone was looking.

Measured after: 26.67 MB → 1.22 MB on load. Scrolling to the bottom still costs the same as before, which is the point. The feature is unchanged; it just stopped charging everyone up front.

The part I didn't expect

With the big number fixed, I looked at LCP. Still 3668 ms on a throttled connection, and the element was my profile photo.

My first assumption was the obvious one: the image is too big. It was a 3024×4032 phone photo, just over 1 MB, being resized down to a 320px circle. So I cropped it, resized it, self-hosted it, and the file went from 1,066,868 bytes to 136,279.

That helped, but it wasn't the main problem.

The main problem was this, in the server-rendered HTML:

<div style="opacity:0;transform:scale(0.8)">

That's Framer Motion's initial={{ opacity: 0, scale: 0.8 }}, plus a 0.2s delay. The image was preloaded, eager, high priority, and completely invisible until React hydrated, Framer Motion booted, and the animation ran.

The photo could finish downloading and you'd still be looking at nothing.

The photo didn't need its own entrance animation. It already had a continuous float, so I just deleted the wrapper causing the delay. The text blocks next to it did need a fade-in, so those got a plain CSS keyframe instead:

@keyframes rise {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}

Same idea in both cases: whatever paints the LCP element can't depend on JavaScript to become visible. Framer Motion's version needs the JS bundle downloaded, parsed, executed, and hydrated before it can run a single frame. The opacity:0 sits there until all of that finishes. The CSS version is handled by the browser's rendering engine directly, starting the moment the stylesheet is parsed, at first paint, with no JavaScript in the loop at all.

metricbeforeafter
LCP3668 ms1328 ms
Image visible by~4500 ms~1500 ms
Wrapper opacity at 1s01

Thirteen elements on that page shipped with inline opacity:0. It also explains something that had confused me earlier: Google Search Console's rendered screenshot of my homepage was almost blank. Its renderer had screenshotted before the animations ran. Same root cause, three different symptoms.

What I took from it

An API that looks like it does the thing is worse than one that obviously doesn't. dynamic(..., { ssr: false }) reads like lazy loading. It isn't, and nothing warns you, because nothing is broken. The page works perfectly. It just costs 26 MB.

"Loaded" and "visible" are different states, and only one of them is instrumented. Every tool told me the image was fine. It was fine. It was also invisible, and no waterfall shows you that. I only found it by recording the load in Chrome DevTools' performance panel and stepping through the filmstrip it captures automatically, frame by frame.

That's the same thing I keep running into with AI systems at work: the model returns something, the request succeeds, every metric is green, and the output is still wrong for the user.

I left a 13 MB download sitting on my own homepage for months without noticing.

Glossary

I used most of these above assuming you already knew them. Quick definitions, roughly in the order they show up.

glTF / .glb. The file format the 3D model is stored in. .glb is the single-file, binary version of glTF, built to ship 3D scenes as compactly as possible. Khronos: glTF overview

three.js. The JavaScript library that renders 3D graphics in the browser using WebGL. @react-three/fiber and drei, the two other libraries in the asset table, are React wrappers around it. threejs.org

SSR (server-side rendering). The server builds the page's full HTML before sending it to the browser, instead of sending an empty shell for JavaScript to fill in later. MDN: Server-side rendering

Dynamic import / code splitting. Loading a piece of JavaScript on demand instead of bundling it into the initial page load. next/dynamic is Next.js's wrapper around this. Next.js: lazy loading

Hydration. The step where React takes server-rendered HTML and attaches its JavaScript behavior to it, turning static markup into something interactive. Nothing can respond to a click, or run an animation, until this finishes. React docs: hydrateRoot

IntersectionObserver. A browser API that reports when an element enters or leaves the viewport, without needing to check scroll position by hand. It's what the 3D model's mount is gated on. MDN: IntersectionObserver

LCP (Largest Contentful Paint). A Core Web Vital measuring how long it takes the biggest visible element on the page, usually an image or a block of text, to actually render. It's one of the numbers Google uses to judge page speed. web.dev: LCP

Framer Motion. A React animation library. It can animate almost anything, but every animation it drives has to wait for its JavaScript to load and run first, which is the whole point of the second half of this post. motion.dev

Google Search Console. Google's tool for seeing how Googlebot actually crawls and renders a site, including a screenshot of what its renderer sees. That screenshot is how I noticed the blank homepage. Search Console