Optimizing Three.js & Next.js: Rationale & Real Performance Impact
Adding an interactive 3D WebGL experience transforms a standard developer portfolio into something truly memorable. However, pairing a heavy 3D engine (Three.js / React Three Fiber) with a Server-Side Rendered framework (Next.js) brought some serious architectural challenges:
- Massive JavaScript Payloads: 3D rendering libraries are inherently heavy (~500KB+ minified/gzipped).
- Server-Side Rendering (SSR) Conflicts: WebGL needs direct access to the DOM and GPU context, which don't exist on the server during pre-rendering.
- High-Frequency Memory Churn at 60 FPS: Creating temporary math objects inside animation loops triggers frequent Garbage Collection (GC) pauses.
- Network Resource Contention: Preloading secondary modal assets steals bandwidth from the initial page paint.
Instead of making blind tweaks, I documented why I made specific architectural decisions and tracked the actual metrics before and after optimization.
Performance Comparison Matrix (Before vs. After)
Here is a summary of the real runtime metrics I measured in my portfolio before and after applying the optimization strategy:
| Metric / Dimension | Before Optimization | After Optimization | Engineering Benefit |
|---|---|---|---|
| Initial JS Bundle (First Load) | ~700KB - 850KB (Synchronously pulled the full 3D engine) | 224KB (UI Shell and core React only) | ~70% reduction in critical payload for first paint. |
| First Contentful Paint (FCP) | 1.8s - 2.5s (Blocked parsing & executing Three.js) | 0.4s - 0.6s (HTML/CSS loader renders instantly) | Immediate perceived responsiveness. |
| Time to Interactive (TTI) | Frozen until WebGL canvas context initialized | Instant (UI controls work while WebGL loads async) | Fluid interaction without main-thread locking. |
| Frame Allocation Churn | ~120 temporary math objects instantiated / sec | 0 objects / sec (Zero-allocation loop using persistent refs) | Completely eliminated GC-induced micro-stutters. |
| GC Pause Frequency | Every 3 - 5 seconds during cube rotation | None during runtime rendering | Rock-solid 60 FPS on desktop and mobile GPUs. |
| Boot Bandwidth Usage | Downloaded profile modal asset (me.png, ~213KB) on initial page load | 0KB modal data on boot (Lazy-loaded on demand) | Bandwidth reserved exclusively for critical boot assets. |
1. Isolating WebGL Execution with Next.js dynamic
The Problem I Ran Into
Next.js 15 pre-renders HTML on the server using Server Components. But WebGL and Three.js rely heavily on window and document.createElement('canvas').
When I imported the 3D canvas directly into the main page layout:
- The server build tried bundling the entire Three.js dependency tree into the main route's JavaScript payload.
- The browser couldn't display any HTML markup until downloading, parsing, and executing the full 3D library stack.
- Users were left looking at a blank screen or a frozen state while the WebGL engine initialized.
How I Solved It
I isolated the entire 3D sub-tree (Canvas, lighting, shaders, instanced geometry, and post-processing) into a decoupled component (PortfolioCanvas) and loaded it dynamically with SSR disabled:
const DynamicCanvas = dynamic(() => import('@/components/GameCube/PortfolioCanvas'), {
ssr: false,
});
The Real Impact
- Instant Critical Paint: The lightweight HTML layout, hero text, and CSS-animated
SceneLoaderrender immediately on the server. - Background Asynchronous Hydration: The 3D engine downloads as an isolated chunk in the background without holding the DOM main thread hostage.
- Graceful Fallback: If a user has hardware acceleration turned off, the interface remains completely functional and readable right away.
2. Zero-Allocation Animation Loop at 60 FPS
The Problem I Ran Into
Rotation physics and mouse-tilt logic run inside a frame loop (requestAnimationFrame or R3F's useFrame), executing 60 to 120 times every second.
Instantiating new objects inside that loop (like new THREE.Quaternion() or new THREE.Euler()) allocates over 120 short-lived objects per second in JavaScript:
- The V8 JavaScript engine stores these objects in the "Young Generation" memory heap.
- As the heap fills up, V8 triggers Garbage Collection (GC) sweeps to clean up memory.
- During GC sweeps, JavaScript execution is paused (a Stop-The-World event), causing dropped frames and noticeable micro-stutters during rotations.
How I Solved It
Instead of allocating fresh objects inside every frame, I pre-allocated persistent mathematical helpers using React's useRef hook once during component mounting. Inside useFrame, I mutate those existing refs in-place with .set(), .setFromEuler(), and .copy():
// Pre-allocate persistent refs once outside the render loop
const targetRotation = useRef(new THREE.Euler());
const currentQuaternion = useRef(new THREE.Quaternion());
useFrame(() => {
// Instead of instantiating `new THREE.Euler()`, mutate the existing ref
targetRotation.current.set(x, y, z);
// Zero memory churn!
});
The Real Impact
- 0 Bytes Allocated per Frame: Memory allocation inside the render loop dropped to zero bytes.
- Sustained 60 FPS: Smooth, stutter-free rotations during dragging, tilting, or section transitions.
- Mobile Efficiency: Eliminating CPU garbage collection cycles lowers battery consumption and device heating on mobile browsers.
3. Bandwidth Prioritization & Deferred Asset Preloading
The Problem I Ran Into
Browsers have strict limits on concurrent HTTP connections during initial page load.
In Next.js, adding priority to an <Image> component forces the framework to inject <link rel="preload"> into the document <head>. While that's crucial for Above-The-Fold hero images, I had accidentally left it on an image hidden inside a profile modal (me.png, ~213KB):
- The modal image was being fetched during the initial boot phase alongside critical fonts, stylesheets, and core scripts.
- The user couldn't even see that image until navigating to the "About" section and clicking to open the modal.
How I Solved It
I removed the priority prop from the modal image component, letting Next.js fall back to native loading="lazy".
The Real Impact
- Unclogged Network Pipeline: Freed up HTTP request slots for essential assets (fonts, interactive scripts, and 3D canvas chunks).
- Data Conservation: Visitors who browse the portfolio without opening the modal never download that 213KB payload.
- Instant Modal Teleportation: The modal frame opens instantly without waiting on background image preloading.
Key Engineering Takeaways
- Decouple Shell UI from Heavy Visual Renders: Never allow heavy 3D or visual features to block the core document layout. Build a fast HTML/CSS shell first, then hydrate visual enhancements asynchronously.
- Respect the 16.6ms Frame Budget: At 60 FPS, each frame has a strict budget of 16.6ms. Memory allocation and garbage collection should never steal budget time from physics and shader updates.
- Auditing Asset Priority is as Critical as Code Optimization: A single misplaced
priorityattribute on a secondary image can negate code-level bundle optimizations by clogging the network pipeline.