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

The 3D particle background on this portfolio runs at 60fps on my machine. That’s irrelevant. What matters is whether it runs on the integrated Intel graphics in someone’s work laptop, the mobile GPU that throttles after 30 seconds, or the Firefox driver on Linux that has a bad week sometimes. I deployed it without thinking about any of that, and the bug reports were exactly what you’d expect.

WebGL performance is unpredictable in a way CSS animations aren’t. CSS hands work to the browser’s compositor, which knows how to optimize it. WebGL puts you in direct control of the GPU — you’re responsible for managing draw calls, buffer uploads, and the balance between visual quality and frame rate.

Most developers solve this with a settings menu: “Graphics Quality: Low/Medium/High.” Users don’t know what their hardware can handle, they pick High, they experience lag, and they blame the site. The ThreeBackground.tsx component solves it differently: start at optimal quality, measure actual frame rate, and degrade automatically if sustained performance is poor.

What You Need Coming In

JavaScript/TypeScript fundamentals, React hooks (useState, useEffect, useRef), basic 3D graphics concepts (camera, scene, objects), and familiarity with the idea of frame rate. Experience with Three.js basics helps, but I’ll explain WebGL-specific details as they come up.

Dependencies from package.json:

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

2000 Draw Calls vs. One: Why BufferGeometry Matters

The first decision is how to create 2000 particles. The instinctive approach creates 2000 individual mesh 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);
}

This produces 2000 draw calls per frame. GPUs are optimized for batching, not thousands of individual objects. On most hardware, this is 10fps.

The right approach packs all 2000 particles into a single BufferGeometry object. One draw call. One GPU buffer upload. The entire particle system is a single THREE.Points object:

const particleCount = isMobile ? 500 : 2000;

const positions = new Float32Array(particleCount * 3);
const colors = new Float32Array(particleCount * 3);

const colorPalette = [
  new THREE.Color(0x00d9ff),
  new THREE.Color(0xa855f7),
  new THREE.Color(0x10b981),
];

for (let i = 0; i < particleCount; i++) {
  // Spherical coordinates for uniform distribution.
  // Using Math.random() directly for x/y/z clusters particles at cube corners.
  const radius = 10;
  const theta = Math.random() * Math.PI * 2;
  const phi = Math.acos(Math.random() * 2 - 1);
  
  positions[i * 3 + 0] = radius * Math.sin(phi) * Math.cos(theta);
  positions[i * 3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
  positions[i * 3 + 2] = radius * Math.cos(phi);
  
  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;
}

const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

const material = new THREE.PointsMaterial({
  size: isMobile ? 0.05 : 0.08,
  vertexColors: true,
  transparent: true,
  opacity: 0.8,
  blending: THREE.AdditiveBlending
});

const particles = new THREE.Points(geometry, material);
scene.add(particles);

The result: 2000 draw calls becomes 1. The CPU-side typed arrays (Float32Array) get uploaded once to a GPU vertex buffer. Rendering 2000 particles costs about 0.5ms instead of ~50ms. On almost any hardware, this hits 60fps.

Refs for Animation State, State for UI

The most common mistake when mixing React with Three.js is putting animation state into useState. State updates trigger re-renders. If you update state in the animation loop, React tries to re-render 60 times per second, which is catastrophic for performance.

The rule: if it changes every frame, use a ref. If it affects what the user sees in JSX, use state.

// Refs — animation state, no 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>();
const lastFrameTimeRef = useRef(Date.now());
const frameCountRef = useRef(0);
const fpsHistoryRef = useRef<number[]>([]);
const mouseRef = useRef({ x: 0, y: 0 });

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

fps is state because it might be displayed to the user. frameCountRef is a ref because it changes every frame and doesn’t need to trigger a re-render.

Measuring FPS Over Time, Not Per Frame

requestAnimationFrame doesn’t tell you the frame rate. It just calls your function when the browser is ready to render. To measure FPS, you count frames over a fixed window:

const animate = () => {
  frameIdRef.current = requestAnimationFrame(animate);
  
  frameCountRef.current++;
  
  const now = Date.now();
  if (now - lastFrameTimeRef.current >= 1000) {
    const currentFps = frameCountRef.current;
    setFps(currentFps);
    
    fpsHistoryRef.current.push(currentFps);
    if (fpsHistoryRef.current.length > 5) {
      fpsHistoryRef.current.shift();
    }
    
    frameCountRef.current = 0;
    lastFrameTimeRef.current = now;
  }
  
  // ... render
};

Why measure over one second rather than per frame? Per-frame times are noisy. Garbage collection, tab switching, browser tasks — any of these can spike a single frame to 50-100ms. A one-second window smooths out variance while remaining responsive enough to detect real performance problems.

The Adaptive Quality System

Detection logic fires only after three seconds of data, to avoid false positives from temporary spikes:

const avgFps = fpsHistoryRef.current.reduce((a, b) => a + b, 0) 
              / fpsHistoryRef.current.length;

if (avgFps < 30 && fpsHistoryRef.current.length >= 3) {
  setLowPerformanceMode(true);
}

When lowPerformanceMode becomes true, a separate effect responds:

useEffect(() => {
  if (lowPerformanceMode && particlesRef.current) {
    const material = particlesRef.current.material as THREE.PointsMaterial;
    
    material.size = Math.max(0.03, material.size * 0.7);
    material.opacity = 0.6;
    
    if (rendererRef.current) {
      rendererRef.current.setPixelRatio(1);
    }
  }
}, [lowPerformanceMode]);

And the animation loop skips every other frame:

if (lowPerformanceMode && frameCountRef.current % 2 === 0) {
  return;  // Skip this frame — must happen BEFORE expensive operations
}

Frame skipping must happen before the render call, not after. Otherwise you’re still doing the work.

Once in low-performance mode, the system doesn’t return to high quality. This prevents thrashing: quality degrades → FPS improves → quality increases → FPS drops → repeat. A one-way degradation is simpler and feels less jittery than oscillation.

Smooth Camera Movement via Exponential Smoothing

Direct assignment based on mouse position produces jerky movement because mouse events fire at irregular intervals:

// Jerky
camera.position.x = (event.clientX / window.innerWidth) * 2 - 1;

Instead, store the target in a ref and lerp toward it each frame:

const mouseRef = useRef({ x: 0, y: 0 });

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

// In the animation loop
cameraRef.current.position.x += (mouseRef.current.x * 2 - cameraRef.current.position.x) * 0.05;
cameraRef.current.position.y += (mouseRef.current.y * 2 - cameraRef.current.position.y) * 0.05;
cameraRef.current.lookAt(0, 0, 0);

Each frame, you move 5% of the remaining distance. After 20 frames (~333ms at 60fps), you’re 95% of the way to the target. This is a low-pass filter — natural easing with no animation library needed.

GPU Memory Is Not Garbage Collected

Unlike JavaScript heap memory, GPU memory must be manually freed. When the component unmounts, you have to explicitly dispose of every Three.js resource:

useEffect(() => {
  // ... initialization
  
  return () => {
    if (frameIdRef.current) {
      cancelAnimationFrame(frameIdRef.current);
    }
    
    if (particlesRef.current) {
      particlesRef.current.geometry.dispose();
      
      if (Array.isArray(particlesRef.current.material)) {
        particlesRef.current.material.forEach(m => m.dispose());
      } else {
        particlesRef.current.material.dispose();
      }
    }
    
    if (rendererRef.current) {
      rendererRef.current.dispose();
    }
    
    window.removeEventListener('mousemove', handleMouseMove);
    window.removeEventListener('resize', handleResize);
  };
}, [intensity, isMobile]);

Forgetting this causes GPU memory leaks that accumulate across route changes. The symptoms are subtle at first — slightly degraded performance — and then the browser tab crashes.

Edge Cases That Will Bite You

Tab switching. When users switch tabs, requestAnimationFrame pauses, but Date.now() keeps ticking. If the user leaves for 10 seconds and comes back, the FPS calculation sees 0 frames in 10 seconds and incorrectly triggers low-performance mode. Fix with the Page Visibility API:

const handleVisibilityChange = () => {
  if (document.hidden) {
    lastFrameTimeRef.current = Date.now();
    frameCountRef.current = 0;
  }
};
document.addEventListener('visibilitychange', handleVisibilityChange);

High refresh rate displays. At 120Hz, the FPS counter reads 120fps, which looks great, but the GPU might be at 95% utilization. Measuring frame time rather than frame count is more accurate: if frameTime > 33.33ms, performance is actually below 30fps regardless of what the counter says.

React Strict Mode. In development, React mounts components twice to surface missing cleanups. Without cancelAnimationFrame in the cleanup, you’d have two animation loops running simultaneously, halving your FPS in dev and making it very hard to debug actual performance issues.

Mobile thermal throttling. Mobile GPUs don’t just run at a fixed speed — they throttle under sustained load. What starts at 60fps drops to 25fps after a minute. The FPS monitor catches this and degrades quality, but you can also pre-empt it by starting in low-performance mode on mobile: const [lowPerformanceMode, setLowPerformanceMode] = useState(isMobile).

The whole system — measure, detect, adapt — is the same feedback loop pattern that shows up in adaptive bitrate streaming and auto-scaling cloud infrastructure. The specific domain changes, the structure doesn’t.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!