featured image

Building a Performance-Adaptive Three.js Particle System with FPS Monitoring

Learn how to create a Three.js particle background that dynamically adjusts quality based on real-time FPS monitoring, ensuring smooth performance across devices.

Published

Mon Jun 02 2025

Technologies Used

Three.js React Javascript Typescript
Advanced 28 minutes

Purpose

The WebGL Performance Cliff

You’ve built a stunning 3D particle background using Three.js. On your MacBook Pro with a discrete GPU, it runs at a buttery-smooth 60fps. You deploy to production, feeling proud. Then the bug reports flood in:

  • “Site is laggy on my work laptop” (integrated Intel graphics)
  • “My phone gets hot after 30 seconds” (mobile GPU thermal throttling)
  • “Firefox on Linux drops to 15fps” (driver issues)

The fundamental problem: WebGL performance is unpredictable. Unlike CSS animations that the browser can optimize, WebGL puts you in direct control of the GPU. You’re responsible for:

  • Managing draw calls and buffer uploads
  • Balancing visual quality against frame rate
  • Detecting performance issues and adapting dynamically

Most developers solve this with a settings menu: “Graphics Quality: Low/Medium/High.” But users don’t know what their hardware can handle. They pick “High,” experience lag, and blame your site—not their 2015 laptop.

Adaptive Quality with Real-Time FPS Monitoring

The code we’re analyzing (src/components/ThreeBackground.tsx) implements a self-tuning particle system that:

  1. Starts with optimal quality (2000 particles, full antialiasing)
  2. Monitors frame rate every second
  3. Detects sustained poor performance (FPS < 30 for 3+ seconds)
  4. Automatically degrades quality (reduce particles, lower pixel ratio, skip frames)
  5. Maintains the visual effect while ensuring usability

This is the same pattern used by AAA games (dynamic resolution scaling) and video streaming (adaptive bitrate), applied to web graphics.

Understanding the Render Pipeline

This tutorial demonstrates five advanced concepts:

  • WebGL Resource Management: Buffer geometry, typed arrays, GPU memory allocation
  • Performance Profiling: FPS calculation, rolling averages, threshold detection
  • Adaptive Systems: Feedback loops that adjust behavior based on measured performance
  • React + Three.js Integration: Managing WebGL lifecycle in React’s component model
  • Mobile Optimization: Device detection, progressive enhancement, thermal management

🔵 Deep Dive: This pattern—measure, detect, adapt—is the foundation of resilient systems. It’s used in auto-scaling cloud infrastructure, adaptive streaming protocols, and real-time trading systems.

Prerequisites & Tooling

Knowledge Base

Required:

  • JavaScript/TypeScript fundamentals
  • React hooks (useState, useEffect, useRef)
  • Basic understanding of 3D graphics concepts (camera, scene, objects)
  • Familiarity with the concept of frame rate

Helpful:

  • Experience with Three.js basics
  • Understanding of WebGL rendering pipeline
  • Knowledge of performance profiling tools (Chrome DevTools)

Environment

From the project’s package.json:

{
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "three": "^0.181.1",
    "@types/three": "^0.181.0"
  }
}

Setup Steps:

# Install dependencies
npm install three @types/three

# Verify TypeScript configuration
cat tsconfig.json | grep "jsx"  # Should show "react-jsx"

Key Concepts:

  • Three.js: JavaScript library that abstracts WebGL
  • BufferGeometry: Efficient way to store vertex data
  • RequestAnimationFrame: Browser API for smooth animations
  • Pixel Ratio: Device pixel density (Retina displays = 2x)

Performance Baseline

To understand the optimizations, you need to measure:

# Start dev server
npm run dev

# Open Chrome DevTools
# 1. Performance tab → Record
# 2. Navigate to page with 3D background
# 3. Stop recording after 5 seconds
# 4. Look for "Scripting" and "Rendering" times

Target Metrics:

  • Desktop: 60fps (16.67ms per frame)
  • Mobile: 30fps minimum (33.33ms per frame)
  • GPU memory: < 50MB for particle system

High-Level Architecture

Render Loop State Machine

stateDiagram-v2
    [*] --> Initialize
    Initialize --> HighQuality: GPU capable
    Initialize --> LowQuality: Mobile detected
    
    HighQuality --> Monitoring: Start FPS tracking
    Monitoring --> Monitoring: FPS >= 30
    Monitoring --> PerformanceCheck: FPS < 30
    
    PerformanceCheck --> Monitoring: Temporary dip
    PerformanceCheck --> DegradeQuality: 3+ seconds low FPS
    
    DegradeQuality --> LowPerformanceMode: Reduce particles
    LowPerformanceMode --> LowPerformanceMode: Skip frames
    
    LowQuality --> [*]: Component unmount
    LowPerformanceMode --> [*]: Component unmount

The Thermostat

Think of the FPS monitor as a smart thermostat:

ThermostatFPS Monitor
Target: 72°FTarget: 60fps
Sensor: TemperatureSensor: Frame time
Actuator: Heater/ACActuator: Quality settings
Hysteresis: ±2°FHysteresis: 3-second window

The Key Insight: You don’t adjust the heater every time the temperature drops 0.1°F—that would cause oscillation. Similarly, you don’t degrade quality on a single dropped frame. You use a rolling average and sustained threshold to avoid thrashing between quality levels.

The Three-Phase Lifecycle

Phase 1: Initialization (useEffect mount)
  ├─ Detect device capabilities (mobile, GPU)
  ├─ Create Three.js scene, camera, renderer
  ├─ Generate particle geometry with typed arrays
  └─ Start animation loop

Phase 2: Steady State (animation loop)
  ├─ Update particle rotation
  ├─ Update camera position (mouse tracking)
  ├─ Render frame
  ├─ Measure frame time
  └─ Check FPS threshold

Phase 3: Adaptation (performance degradation)
  ├─ Detect sustained low FPS
  ├─ Reduce particle size
  ├─ Lower pixel ratio
  ├─ Enable frame skipping
  └─ Continue monitoring

The Implementation

Component State and Refs

Naive Approach: Store Everything in State

// WRONG: Causes re-renders on every frame
const [scene, setScene] = useState<THREE.Scene>();
const [camera, setCamera] = useState<THREE.Camera>();
const [particles, setParticles] = useState<THREE.Points>();

// This triggers re-render 60 times per second!
setParticles(newParticles);

Why This Fails: React state updates trigger re-renders. If you update state in the animation loop (60fps), React tries to re-render 60 times per second, causing massive performance overhead.

Refined Solution (From Repo):

// Refs persist across renders without triggering re-renders
const canvasRef = useRef<HTMLCanvasElement>(null);
const sceneRef = useRef<THREE.Scene>();
const cameraRef = useRef<THREE.PerspectiveCamera>();
const rendererRef = useRef<THREE.WebGLRenderer>();
const particlesRef = useRef<THREE.Points>();
const frameIdRef = useRef<number>();

// State only for values that affect UI rendering
const [isMobile, setIsMobile] = useState(false);
const [fps, setFps] = useState(60);
const [lowPerformanceMode, setLowPerformanceMode] = useState(false);

🔴 Danger: Mixing refs and state incorrectly is the #1 cause of performance issues in React + Three.js apps. Rule of thumb: If it changes every frame, use a ref. If it affects JSX rendering, use state.

Efficient Particle Generation

Naive Approach: Individual Mesh Objects

// WRONG: Creates 2000 separate objects
for (let i = 0; i < 2000; i++) {
  const geometry = new THREE.SphereGeometry(0.05);
  const material = new THREE.MeshBasicMaterial({ color: 0x00d9ff });
  const particle = new THREE.Mesh(geometry, material);
  scene.add(particle);
}
// Result: 2000 draw calls, 10fps

Why This Fails: Each mesh is a separate draw call. GPUs are optimized for batch rendering, not thousands of individual objects.

Refined Solution (From Repo):

// Determine particle count based on device
const particleCount = isMobile ? 500 : 2000;

// Pre-allocate typed arrays (GPU-friendly)
const positions = new Float32Array(particleCount * 3);  // x, y, z per particle
const colors = new Float32Array(particleCount * 3);     // r, g, b per particle

// Color palette for variety
const colorPalette = [
  new THREE.Color(0x00d9ff), // Cyan
  new THREE.Color(0xa855f7), // Purple
  new THREE.Color(0x10b981), // Green
];

for (let i = 0; i < particleCount; i++) {
  // Generate position using spherical coordinates
  const radius = 10;
  const theta = Math.random() * Math.PI * 2;      // Azimuthal angle
  const phi = Math.acos(Math.random() * 2 - 1);   // Polar angle
  
  // Convert spherical to Cartesian coordinates
  positions[i * 3 + 0] = radius * Math.sin(phi) * Math.cos(theta); // x
  positions[i * 3 + 1] = radius * Math.sin(phi) * Math.sin(theta); // y
  positions[i * 3 + 2] = radius * Math.cos(phi);                    // z
  
  // Assign random color from palette
  const color = colorPalette[Math.floor(Math.random() * colorPalette.length)];
  colors[i * 3 + 0] = color.r;
  colors[i * 3 + 1] = color.g;
  colors[i * 3 + 2] = color.b;
}

🔵 Deep Dive: Why spherical coordinates? If you use Math.random() for x, y, z directly, particles cluster at the cube corners. Spherical coordinates ensure uniform distribution on a sphere’s surface.

Creating the Geometry:

const geometry = new THREE.BufferGeometry();

// Attach typed arrays as attributes
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

// Material configuration
const material = new THREE.PointsMaterial({
  size: isMobile ? 0.05 : 0.08,
  vertexColors: true,        // Use per-particle colors
  transparent: true,
  opacity: 0.8,
  blending: THREE.AdditiveBlending  // Glow effect
});

// Single Points object = single draw call
const particles = new THREE.Points(geometry, material);
scene.add(particles);
particlesRef.current = particles;

Performance Impact:

  • Before: 2000 draw calls, ~10fps
  • After: 1 draw call, ~60fps
  • 6x performance improvement

FPS Monitoring System

The Challenge: requestAnimationFrame doesn’t tell you the frame rate—it just calls your function when the browser is ready to render.

Solution: Track frame count over time:

// State for FPS tracking
const [fps, setFps] = useState(60);
const lastFrameTimeRef = useRef(Date.now());
const frameCountRef = useRef(0);
const fpsHistoryRef = useRef<number[]>([]);

// Inside animation loop
const animate = () => {
  frameIdRef.current = requestAnimationFrame(animate);
  
  // Increment frame counter
  frameCountRef.current++;
  
  // Calculate FPS every second
  const now = Date.now();
  if (now - lastFrameTimeRef.current >= 1000) {
    const currentFps = frameCountRef.current;
    setFps(currentFps);  // Update UI
    
    // Track history for trend analysis
    fpsHistoryRef.current.push(currentFps);
    if (fpsHistoryRef.current.length > 5) {
      fpsHistoryRef.current.shift();  // Keep last 5 seconds
    }
    
    // Reset counters
    frameCountRef.current = 0;
    lastFrameTimeRef.current = now;
  }
  
  // ... render logic
};

🔵 Deep Dive: Why measure over 1 second instead of per-frame? Individual frame times are noisy (garbage collection, browser tasks). A 1-second window smooths out variance while remaining responsive.

Adaptive Quality System

Detection Logic:

// Inside the FPS calculation block
const avgFps = fpsHistoryRef.current.reduce((a, b) => a + b, 0) 
              / fpsHistoryRef.current.length;

// Trigger degradation if:
// 1. Average FPS < 30 (below acceptable threshold)
// 2. We have at least 3 seconds of data (avoid false positives)
if (avgFps < 30 && fpsHistoryRef.current.length >= 3) {
  setLowPerformanceMode(true);
}

Quality Degradation (Separate useEffect):

useEffect(() => {
  if (lowPerformanceMode && particlesRef.current) {
    const material = particlesRef.current.material as THREE.PointsMaterial;
    
    // Reduce particle size by 30%
    material.size = Math.max(0.03, material.size * 0.7);
    
    // Lower opacity (less overdraw)
    material.opacity = 0.6;
    
    // Reduce pixel ratio (fewer pixels to render)
    if (rendererRef.current) {
      rendererRef.current.setPixelRatio(1);
    }
    
    console.log('Low performance mode enabled - reducing quality');
  }
}, [lowPerformanceMode]);

Frame Skipping:

// Inside animation loop
if (lowPerformanceMode && frameCountRef.current % 2 === 0) {
  return;  // Skip every other frame (30fps instead of 60fps)
}

🔴 Danger: Frame skipping must happen before expensive operations (rendering), not after. Otherwise, you’re still doing the work.

Smooth Camera Movement

Naive Approach: Direct Assignment

// WRONG: Jerky movement
const handleMouseMove = (event: MouseEvent) => {
  camera.position.x = (event.clientX / window.innerWidth) * 2 - 1;
  camera.position.y = -(event.clientY / window.innerHeight) * 2 + 1;
};

Why This Fails: Mouse events fire at irregular intervals. Direct assignment causes stuttering.

Refined Solution: Linear Interpolation (Lerp)

// Store mouse position in ref (doesn't trigger re-render)
const mouseRef = useRef({ x: 0, y: 0 });

const handleMouseMove = (event: MouseEvent) => {
  // Normalize to -1 to 1 range
  mouseRef.current.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouseRef.current.y = -(event.clientY / window.innerHeight) * 2 + 1;
};

// In animation loop
const targetX = mouseRef.current.x * 2;
const targetY = mouseRef.current.y * 2;

// Move 5% of the distance each frame (exponential smoothing)
cameraRef.current.position.x += (targetX - cameraRef.current.position.x) * 0.05;
cameraRef.current.position.y += (targetY - cameraRef.current.position.y) * 0.05;
cameraRef.current.lookAt(0, 0, 0);

The Math: This is a low-pass filter. Each frame, you move 5% closer to the target. After 20 frames (~333ms at 60fps), you’re 95% of the way there. This creates natural easing without animation libraries.

Cleanup and Memory Management

The Problem: Three.js objects hold GPU resources. If you don’t dispose of them, you leak memory.

useEffect(() => {
  // ... initialization code
  
  return () => {
    // Cleanup function runs on unmount
    
    // Cancel animation loop
    if (frameIdRef.current) {
      cancelAnimationFrame(frameIdRef.current);
    }
    
    // Dispose of Three.js resources
    if (particlesRef.current) {
      particlesRef.current.geometry.dispose();
      
      // Material might be array or single object
      if (Array.isArray(particlesRef.current.material)) {
        particlesRef.current.material.forEach(m => m.dispose());
      } else {
        particlesRef.current.material.dispose();
      }
    }
    
    // Dispose of renderer
    if (rendererRef.current) {
      rendererRef.current.dispose();
    }
    
    // Remove event listeners
    window.removeEventListener('mousemove', handleMouseMove);
    window.removeEventListener('resize', handleResize);
  };
}, [intensity, isMobile]);

🔴 Danger: Forgetting to dispose of geometries and materials causes GPU memory leaks. Unlike JavaScript heap memory (garbage collected), GPU memory must be manually freed.

Under the Hood

WebGL Resource Management

What Happens When You Create BufferGeometry:

  1. CPU Side (JavaScript):

    const positions = new Float32Array(6000);  // 24KB on heap
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  2. GPU Side (WebGL):

    // Three.js internally calls:
    glGenBuffers(1, &vbo);                    // Create buffer ID
    glBindBuffer(GL_ARRAY_BUFFER, vbo);       // Bind buffer
    glBufferData(GL_ARRAY_BUFFER, 24KB, positions, GL_STATIC_DRAW);  // Upload to GPU
  3. Memory Layout:

    CPU Heap:     [Float32Array: 24KB]
                         ↓ (upload)
    GPU VRAM:     [Vertex Buffer: 24KB]

Performance Characteristics:

  • Upload Time: ~1ms for 24KB (PCIe bandwidth: ~16GB/s)
  • Render Time: ~0.5ms for 2000 particles (GPU parallel processing)
  • Total Frame Budget: 16.67ms (60fps) - 1.5ms = 15.17ms remaining for other work

FPS Calculation Accuracy

Why 1-Second Windows?

Frame times follow a bimodal distribution:

  • Most frames: 16.67ms (60fps)
  • Occasional spikes: 50-100ms (garbage collection, tab switching)
Frame Times (ms):
16, 16, 17, 16, 16, 85, 16, 16, 17, 16  (one GC spike)

Per-Frame FPS:
60, 60, 58, 60, 60, 11, 60, 60, 58, 60  (misleading!)

1-Second Average:
10 frames / 0.271s = 36.9fps  (more accurate)

Statistical Properties:

  • Mean: Affected by outliers (GC spikes)
  • Median: More robust, but harder to calculate incrementally
  • Rolling Average: Good compromise (implemented in code)

Adaptive System Stability

The Control Theory Perspective:

This is a proportional controller with hysteresis:

Error = Target FPS - Actual FPS
If Error > 30 for 3 seconds:
  Apply correction (reduce quality)

Why 3 Seconds?

  • Too short (1s): False positives from temporary spikes
  • Too long (10s): Users experience lag before adaptation
  • 3 seconds: Balances responsiveness and stability

Oscillation Prevention:

Once in low-performance mode, the system never returns to high quality. Why?

// No code to set lowPerformanceMode back to false

This prevents thrashing: quality degrades → FPS improves → quality increases → FPS drops → repeat.

Production Enhancement (not in repo):

// Allow recovery after sustained good performance
if (lowPerformanceMode && avgFps > 50 && fpsHistoryRef.current.length >= 10) {
  setLowPerformanceMode(false);
}

Memory Footprint Analysis

Per-Particle Memory:

Position: 3 floats × 4 bytes = 12 bytes
Color:    3 floats × 4 bytes = 12 bytes
Total:    24 bytes per particle

Total Memory (2000 particles):

Geometry:  2000 × 24 bytes = 48KB (CPU)
           2000 × 24 bytes = 48KB (GPU)
Material:  ~1KB (shader uniforms)
Renderer:  ~10MB (framebuffer, depth buffer)
Total:     ~10.1MB

Comparison to Alternatives:

ApproachMemoryDraw CallsFPS
Individual meshes~200MB200010
BufferGeometry~10MB160
Savings95%99.95%6x

Edge Cases & Pitfalls

Tab Visibility

Problem: When users switch tabs, requestAnimationFrame pauses, but Date.now() keeps ticking.

// User switches tabs for 10 seconds
// Returns to tab
// FPS calculation: 0 frames / 10 seconds = 0 FPS
// System incorrectly triggers low-performance mode

Solution: Use Page Visibility API:

useEffect(() => {
  const handleVisibilityChange = () => {
    if (document.hidden) {
      // Pause FPS tracking
      lastFrameTimeRef.current = Date.now();
      frameCountRef.current = 0;
    }
  };
  
  document.addEventListener('visibilitychange', handleVisibilityChange);
  return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);

High Refresh Rate Displays

Problem: 120Hz/144Hz monitors run at higher frame rates.

// User has 120Hz display
// FPS counter shows 120fps
// System thinks performance is great
// But GPU is actually struggling (high utilization)

Solution: Measure frame time, not frame rate:

const frameTime = now - lastFrameTimeRef.current;
if (frameTime > 33.33) {  // Slower than 30fps
  // Degrade quality
}

Mobile Thermal Throttling

Problem: Mobile GPUs throttle after sustained load.

t=0s:   60fps (cold start)
t=30s:  45fps (warming up)
t=60s:  25fps (thermal throttle)

Current Behavior: System detects low FPS and degrades quality.

Enhancement: Detect mobile and start in low-performance mode:

const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);
const [lowPerformanceMode, setLowPerformanceMode] = useState(isMobile);

React Strict Mode Double Rendering

Problem: In development, React Strict Mode mounts components twice.

useEffect(() => {
  // This runs twice in development!
  const scene = new THREE.Scene();
  // ...
}, []);

Result: Two animation loops running simultaneously, halving FPS.

Solution: Proper cleanup (already in code):

return () => {
  if (frameIdRef.current) {
    cancelAnimationFrame(frameIdRef.current);
  }
};

Security: GPU Fingerprinting

Risk: WebGL exposes GPU information that can be used for tracking:

const renderer = new THREE.WebGLRenderer();
const debugInfo = renderer.getContext().getExtension('WEBGL_debug_renderer_info');
const gpu = renderer.getContext().getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
// "NVIDIA GeForce RTX 3080" - unique identifier

Mitigation: This code doesn’t query GPU info, but be aware that Three.js apps can be fingerprinted.

Memory Leaks in Event Listeners

Problem: Event listeners hold references to closures, preventing garbage collection.

// WRONG: Creates new function every render
useEffect(() => {
  window.addEventListener('mousemove', (e) => {
    mouseRef.current.x = e.clientX;
  });
}, []);  // Missing cleanup!

Solution (from repo):

const handleMouseMove = (event: MouseEvent) => {
  mouseRef.current.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouseRef.current.y = -(event.clientY / window.innerHeight) * 2 + 1;
};

useEffect(() => {
  window.addEventListener('mousemove', handleMouseMove);
  return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);

Conclusion

Skills Acquired

You’ve learned:

  1. WebGL Resource Management: Efficient use of BufferGeometry and typed arrays for GPU-friendly data structures
  2. Performance Profiling: Real-time FPS monitoring with rolling averages and threshold detection
  3. Adaptive Systems: Feedback loops that measure performance and adjust quality dynamically
  4. React + Three.js Integration: Managing WebGL lifecycle within React’s component model without performance penalties
  5. Mobile Optimization: Device detection, progressive enhancement, and thermal management strategies

The Proficiency Marker: Most developers treat 3D graphics as “it either works or it doesn’t.” You now understand graphics as a resource-constrained optimization problem where you must balance visual quality against performance across diverse hardware. This mental model transfers to:

  • Video streaming (adaptive bitrate)
  • Game development (dynamic resolution scaling)
  • Cloud infrastructure (auto-scaling based on metrics)
  • Real-time systems (deadline scheduling)

Using This Component

In your Astro page:

---
// src/pages/index.astro
import ThreeBackground from '@/components/ThreeBackground';
---

<html>
  <body>
    <div class="content">
      <!-- Your page content -->
    </div>
    
    <!-- 3D background (non-interactive) -->
    <ThreeBackground client:load intensity={1.0} />
  </body>
</html>

Customization Options:

interface ThreeBackgroundProps {
  intensity?: number;  // 0.0 to 2.0 (rotation speed)
}

// Slow rotation
<ThreeBackground intensity={0.5} />

// Fast rotation
<ThreeBackground intensity={2.0} />

Next Challenge: Implement GPU particle physics using compute shaders (WebGL 2.0) to simulate particle interactions (attraction/repulsion) while maintaining 60fps.

Performance Checklist

When building WebGL experiences, always:

  • ✅ Use BufferGeometry, not individual meshes
  • ✅ Monitor FPS and adapt quality dynamically
  • ✅ Dispose of Three.js resources on unmount
  • ✅ Use refs for animation state, not React state
  • ✅ Implement proper event listener cleanup
  • ✅ Test on low-end devices (not just your MacBook)
  • ✅ Consider mobile thermal throttling
  • ✅ Use Page Visibility API to pause when hidden

The Golden Rule: Measure first, optimize second, adapt always.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!