The Problem
While developing my personal website, I noticed something strange: after running npm run dev, opening the homepage and leaving it idle would cause memory usage to climb continuously. Even more puzzling, CPU usage spiked to 100%.
Round 1: Suspecting Browser-Side Components
Initial Analysis
I first focused on all visible components on the homepage. A typical portfolio hero screen contains these dynamic elements:
| Component | Dynamic Behavior | Suspicion Level |
|---|---|---|
| FloatingParticles | Canvas particle animation, creating/destroying objects per frame | ⭐⭐⭐⭐⭐ |
| HeroSection | Typewriter effect, ~12.5 re-renders/sec | ⭐⭐⭐⭐ |
| MouseGlow | Continuous requestAnimationFrame loop | ⭐⭐⭐ |
| TiltCard | Mouse-follow tilt effect | ⭐⭐ |
Issues Found
1. FloatingParticles: Creating Garbage Every Frame
The original implementation performed these operations every single frame:
// filter() creates a new array each frame
particles = particles.filter(p => p.life > 0);
// Dead particles replaced with new objects
if (particles.length < maxParticles) {
particles.push(createParticle()); // New object allocation
}
This means 60 frames/sec × N particles = massive numbers of short-lived objects, putting enormous pressure on the GC. Additionally, font sizes used random floating-point numbers:
const fontSize = Math.random() * 20 + 10; // 10.0001, 15.2345...
Each unique float value causes the browser to create a new font cache entry—the font cache grows infinitely.
2. HeroSection: Typing Effect Cascading Re-renders
The useTypingEffect Hook updates state every 80ms, causing the entire HeroSection component tree (including AnimatedName, TechTags, SocialLinks, etc.) to re-render completely. None of the child components used React.memo for optimization.
3. TiltCard: Missing RAF Cleanup
The component didn't call cancelAnimationFrame on unmount, so animation frame callbacks continued executing after the component was removed.
4. Header: fetch Without AbortController
The authentication request had no AbortController, meaning fetch could still be in progress after component unmount.
Round 1 Fixes
I made the following changes:
- FloatingParticles: Switched to object pool pattern—particles are reused via an
aliveflag instead of being destroyed/recreated; predefined 10 integer font sizes; added font string cache Map - HeroSection: Extracted TypingText as a separate component; wrapped AnimatedName and TechTags with
React.memo - TiltCard: Added useEffect cleanup for RAF
- Header: Added AbortController to fetch
Result: Problem Persists
After restarting the project, CPU was still at 100%. While Round 1's fixes reduced browser-side memory allocation and unnecessary re-renders, they clearly weren't the root cause.
Round 2: Shifting to CSS Rendering Pipeline
Since browser-side JavaScript wasn't the main culprit, could it be CSS rendering?
Analyzing the Rendering Pipeline
The homepage had multiple layers of visual effects stacked together:
- MouseGlow:
blur-[100px]massive blur glow + 60fps RAF loop - hero-gradient-bg:
background-positionanimation (cannot be GPU-composited) - dot-matrix-glass:
backdrop-filter: saturate(50%) blur(4px)(frosted glass effect)
These effects formed a rendering pipeline vicious cycle: RAF triggers repaint → backdrop-filter forces CPU composition → background-position animation triggers full-layer repaint → blur re-composes → cycle repeats.
Round 2 Fixes
- MouseGlow: Removed continuous RAF loop, only updates on mousemove events
- hero-gradient-bg: Changed
background-positionanimation totransform: translate3d()animation (GPU-compositable) - dot-matrix-glass: Replaced
backdrop-filterwith semi-transparent background color
Critical Turning Point
This time I reverted the code. The reason was discovering a crucial clue:
Even on the blog list page (not the homepage), CPU usage was still at 100%!
This meant the problem wasn't specific to any particular page's rendering effects—it was global. Round 2's direction was wrong, so I immediately reverted all changes.
Round 3: Global Components + Process Monitoring
Investigating All-Pages Shared Components
By analyzing the route structure, I found that all pages were wrapped in PortfolioPageWrapper, which contains these global components:
- MouseGlow — Global mouse glow
- Header — Navigation bar (with fetch auth requests)
- Footer — Page footer
- ChatWidget — AI chat widget (with setInterval typewriter, useSyncExternalStore)
After reviewing each one individually, while they had room for optimization, none could explain 100% CPU.
The Decisive Step: Monitoring the Node.js Process
Since I couldn't find the cause in the browser, I turned my attention to the server-side process. Using PowerShell to monitor the Node.js process resource usage:
# Turbopack mode
Get-Process node | Select-Object Id, CPU, WorkingSet64
# Result: PID 38420, CPU: 107%, Memory: 2755MB → 3089MB (growing)
# Contrast: Webpack mode
Get-Process node | Select-Object Id, CPU, WorkingSet64
# Result: PID 4748, CPU: ~0%, Memory: 1016MB (stable)
The data was crystal clear:
| Mode | CPU Usage | Memory Usage | Trend |
|---|---|---|---|
| Turbopack | 107% | 2755 → 3089 MB | ⬆️ Continuously growing |
| Webpack | ~0% | 1016 MB | ➡️ Stable |
Root Cause: Official Turbopack Bug
After three rounds of investigation, the final conclusion was clear: the culprit behind 100% CPU and continuous memory growth is a Bug in Next.js 16's Turbopack development server itself.
Known Official Issues
Multiple related issues exist on GitHub:
| Issue | Description | Version |
|---|---|---|
| #92246 | Turbopack dev server OOM: heap grows to 17GB+ when idle | 16.2.1 |
| #93069 | Memory leak fix merged in canary but not released to 16.x stable after 45 days | 16.2.4 |
| #86893 | next-server becomes zombie with 400-700% CPU | - |
| #87322 | Turbopack infinite compilation loop | - |
Technical Root Cause
Two core fix PRs have been merged into the canary branch but are not included in the 16.2.4 stable release:
- PR #88577: tee'd response clones weren't registered with
FinalizationRegistry, causing ArrayBuffer/WriteWrap leaks - PR #89040: LRU cache entries had no minimum size limit, allowing unbounded growth
Final Fix
The solution was surprisingly simple—switch to Webpack mode in package.json:
{
"scripts": {
"dev": "next dev --webpack"
}
}
Verification result: CPU dropped from 107% to ~0%, memory stabilized from 3GB+ and growing to ~837MB.
Lessons Learned
Evolution of Debugging Approach
Round 1: Browser JS layer → Found real issues but not the root cause
↓
Round 2: Browser CSS rendering layer → Wrong direction, corrected by user feedback
↓
Round 3: Global components + Server-side process → Found the true root cause
Key Takeaways
- Think across layers: When single-layer investigation hits dead ends, dare to cross boundaries (browser → Node.js process)
- Control variable verification: The observation that "blog list page also shows 100% CPU" was the critical turning point, ruling out page-specific factors
- Let data speak: Don't guess—directly measure process-level CPU/memory metrics, data doesn't lie
- Official bugs are common: Even mature frameworks' latest versions can have serious bugs—check the Issue Tracker first when encountering anomalies
- Revert quickly: When you discover your fix direction is wrong, revert decisively to avoid introducing unnecessary changes
Bonus Benefits
While the Turbopack Bug was the primary cause, Round 1's browser-side optimizations remain valuable improvements:
- Object pool pattern reduces GC pressure
- React.memo reduces unnecessary re-renders
- RAF cleanup prevents memory leaks
- AbortController prevents dangling requests
These optimizations are equally effective in production environments (built with Webpack).