I rebuilt my portfolio in <50KB of JavaScript
Killed Three.js, GSAP, Lenis, postprocessing, splitting, and canvas-confetti. Site dropped from ~800KB JS to under 50KB. Here's what I kept, what I cut, and the perf math.
The old version of this site was beautiful in screenshots and brutal in production. Three rotating WebGL scenes, a custom cursor, mouse ribbons, post-processing shaders, smooth-scroll inertia, and a 2,500-line vanilla JS boot script. First Contentful Paint sat north of three seconds on a mid-range Android. Largest Contentful Paint was a comedy. I am a developer. My portfolio cannot be slow. Worse, it cannot be slow on the device most of my visitors actually use, which is a four-year-old Android phone with a poor 4G connection. So I tore it down.
What was on the old site
Three.js scenes for hero, projects, and contact. Each scene was 800KB-ish gzipped just for the library, plus another 50-100KB of custom shader, geometry, and material code. Postprocessing for bloom and vignette ran another 80KB. GSAP for scroll-driven timelines. Lenis for smooth-scroll inertia. Splitting.js to chop headlines into per-letter spans. Canvas-confetti when you submitted the contact form. FontAwesome on every page because I had used four icons. Ten 3D scenes. A loading screen that ran for at least 1.2 seconds on broadband and longer on mobile. A side rail. A mobile overlay. A WhatsApp sticky pill. A custom cursor that fought every link's hover state on touchscreens.
The total JavaScript shipped to a first-time mobile visitor was about 800KB after gzip. Lighthouse on a Moto G4 profile gave the homepage a performance score in the 30s. INP — the new Core Web Vital that replaced FID in March 2024 — hovered around 600 milliseconds on the project grid because the tilt-and-shadow handler ran on every mousemove and competed with the WebGL render loop.
What I cut
Three.js. Postprocessing. GSAP. Lenis. Splitting. Canvas-confetti. FontAwesome. The custom cursor. The loading screen. The side rail. The mobile overlay. The sticky pill. All of it. Replaced by zero JavaScript on most sections, and a pure CSS conic-gradient drift on the hero.
I want to be specific about why each thing left. Three.js left because the WebGL scenes did not communicate anything about my actual work. They were spectacle for spectacle's sake. Postprocessing was even worse — I was applying a bloom shader on top of a scene that already worked fine. GSAP and Lenis were a habit, not a need. Modern CSS and the IntersectionObserver API cover ninety percent of what I used GSAP for, with no JavaScript dependency. Canvas-confetti was the worst kind of dead code: 6KB of library shipped on every visit so that a user who fills the form gets a celebration animation that lasts 800 milliseconds and is then forgotten.
What I kept
Geist Sans and Geist Mono via next/font, served self-hosted with display: swap. Lucide-react for icons, tree-shaken so the homepage ships maybe twelve icons total rather than the entire library. Next.js 16 App Router. ISR with sixty-second revalidation for content-driven pages. Theme is dark-first with a light toggle stored in localStorage and applied pre-paint via a tiny inline script in the <head>.
I kept React, obviously. The team behind React Server Components has been right about the cost of client-side JavaScript for years, and Next.js App Router is the cleanest production implementation of that thesis. The homepage is now mostly server-rendered HTML with a few client islands — theme toggle, scroll progress bar, magnetic CTAs, tilt cards, command palette. Each island is small, isolated, and only hydrates when it is needed.
How I rebuilt the wow
Spectacle does not require WebGL. The hero now uses a CSS conic-gradient mesh that drifts on a thirty-second alternate animation, plus a cursor-follow accent spot blended over the top. The accent spot is a single radial-gradient div that updates its position via a CSS custom property bound to mousemove on the hero element only. No WebGL. No animation library. No frame loop running when nobody is looking at the screen.
Project cards lift and tilt on mousemove with a four-degree perspective and a soft spotlight that tracks the pointer. The handler runs requestAnimationFrame-throttled and only attaches on devices with a fine pointer — touchscreens get a static card with a press-down effect on tap. Section reveals are an IntersectionObserver toggling a single data attribute on the wrapping element. The marquee is pure CSS keyframes with a duplicated child to avoid the jump at the loop boundary.
The result is a site that still feels alive on first load, runs at a steady sixty frames per second on every device I have tested, and ships about thirty kilobytes of JavaScript over the wire to a cold visitor on the homepage.
The perf budget I committed to
Hard targets: under fifty kilobytes of JavaScript shipped to the homepage on first load, sub-1.5-second Largest Contentful Paint on a fast 4G connection, sub-100-millisecond Total Blocking Time, sub-200-millisecond Interaction to Next Paint, Lighthouse performance score of ninety-five or above. I treated the budget the way you would treat a contract: the build pipeline fails if the homepage bundle exceeds fifty kilobytes after gzip. No exceptions. No 'temporary' regressions.
Bundle analysis, before and after
Before: 802 kilobytes JavaScript transferred for the homepage. Three.js was 612 KB. Postprocessing was 78 KB. GSAP and ScrollTrigger together were 41 KB. Lenis was 7 KB. Canvas-confetti was 6 KB. The custom 2,500-line boot script gzipped to 38 KB. The remainder was the React runtime and Next.js framework code.
After: 47 kilobytes JavaScript transferred for the homepage. The React 19 + Next.js 16 framework code is 36 KB. Lucide-react adds 4 KB for the dozen icons used. The remaining 7 KB covers theme toggle, scroll progress, command palette, tilt cards, and intersection observers. The single biggest win came from realizing how much of the framework code Next.js can stream and how little custom JavaScript a portfolio actually needs to feel polished.
Lighthouse comparison
Before, on a Moto G4 throttled profile: performance 34, accessibility 88, best practices 92, SEO 91. LCP 4.8 seconds. CLS 0.18 from late-hydrated WebGL canvases. TBT 1,640 ms. After, same profile: performance 99, accessibility 100, best practices 100, SEO 100. LCP 1.3 seconds. CLS 0.00. TBT 30 ms. INP measured separately on real devices via web-vitals reporting: 56 ms on the homepage's worst interaction (the command palette open). The contrast is not subtle.
What I learned
If your portfolio renders the wow before the content, you have built marketing for yourself, not engineering. The point of a developer portfolio is to convince another developer that you ship fast, considered work. The site itself is the proof. Treat it like a product, not a demo.
The hardest thing about this rebuild was not technical. It was psychological. I had been proud of the WebGL scenes. I had spent weeks on the shader work. Cutting them felt like throwing away weeks of effort. But the visitor does not care about my effort. The visitor cares whether the page renders fast enough that they form an impression of competence. WebGL scenes that take three seconds to appear give the opposite impression to the one I wanted.
The second thing I learned: small frameworks plus boring CSS plus IntersectionObserver covers ninety percent of what people reach for animation libraries to do. The exceptions — genuinely complex timeline orchestration, physics-driven motion, elaborate scroll-linked sequences — are real, but they are rare on portfolio sites. Reach for the heavy library only when CSS and the platform have actually run out of road.
Fastest portfolios convert. They communicate competence in the first second. They get crawled cleanly. They pass Core Web Vitals so search engines surface them. They cost less to host. The arguments for shipping a fast site are stronger than the arguments for shipping a beautiful one, and the best sites do both.