My portfolio was shipping 26MB and I had no idea
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.
| asset | size | what it is |
|---|---|---|
dance.glb | 13.42 MB | a decorative 3D avatar |
| decoded HDR | 9.78 MB | lighting data, in memory |
venice_sunset_1k.hdr | 1.33 MB | fetched from GitHub at runtime |
| three.js | 0.65 MB | the 3D engine |
| drei | 0.27 MB | three.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.
| metric | before | after |
|---|---|---|
| LCP | 3668 ms | 1328 ms |
| Image visible by | ~4500 ms | ~1500 ms |
| Wrapper opacity at 1s | 0 | 1 |
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