•
Next.js / React Three Fiber / Performance / Software Architecture / WebGL

Optimizing Three.js & Next.js: Rationale & Real Performance Impact

A practical deep-dive into how I optimized performance in my 3D Next.js portfolio, cutting initial bundle size by 70% and eliminating garbage collection stutters.

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:

  1. Massive JavaScript Payloads: 3D rendering libraries are inherently heavy (~500KB+ minified/gzipped).
  2. 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.
  3. High-Frequency Memory Churn at 60 FPS: Creating temporary math objects inside animation loops triggers frequent Garbage Collection (GC) pauses.
  4. 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 / DimensionBefore OptimizationAfter OptimizationEngineering 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 initializedInstant (UI controls work while WebGL loads async)Fluid interaction without main-thread locking.
Frame Allocation Churn~120 temporary math objects instantiated / sec0 objects / sec (Zero-allocation loop using persistent refs)Completely eliminated GC-induced micro-stutters.
GC Pause FrequencyEvery 3 - 5 seconds during cube rotationNone during runtime renderingRock-solid 60 FPS on desktop and mobile GPUs.
Boot Bandwidth UsageDownloaded profile modal asset (me.png, ~213KB) on initial page load0KB 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 SceneLoader render 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

  1. Unclogged Network Pipeline: Freed up HTTP request slots for essential assets (fonts, interactive scripts, and 3D canvas chunks).
  2. Data Conservation: Visitors who browse the portfolio without opening the modal never download that 213KB payload.
  3. Instant Modal Teleportation: The modal frame opens instantly without waiting on background image preloading.

Key Engineering Takeaways

  1. 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.
  2. 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.
  3. Auditing Asset Priority is as Critical as Code Optimization: A single misplaced priority attribute on a secondary image can negate code-level bundle optimizations by clogging the network pipeline.