DEV Community

Cover image for Why your React Three Fiber gallery drops to 5 FPS and how to fix it
Alan West
Alan West

Posted on

Why your React Three Fiber gallery drops to 5 FPS and how to fix it

Last month a friend asked me to help debug a 3D model gallery he was building. Beautiful idea: a scrollable showcase of about fifty detailed meshes, all rendered with React Three Fiber. The problem? It looked like a slideshow on a Pentium 3.

His MacBook Pro was wheezing. My M2 was hitting 8 FPS once everything mounted. I've shipped a handful of WebGL-heavy apps over the years, and this is by far the most common failure mode I see in React Three Fiber projects. The fix is almost always the same set of moves — but you have to actually identify which one your scene is choking on.

The symptoms

Here's what you usually notice, in order:

  • Page loads fine, hits 60 FPS with one model.
  • Add more models. FPS starts crawling.
  • Browser GPU process eats 2+ GB of RAM.
  • Hot reload makes it worse every time.
  • After 5 minutes, the tab crashes with WebGL context lost.

If any of that sounds familiar, you're almost certainly hitting one of three issues: too many draw calls, leaked GPU resources, or both at once.

Step 1: Find your actual bottleneck

Before touching code, install the r3f-perf panel. Don't skip this. I've watched developers spend hours "optimizing" the wrong thing because they assumed they knew what was slow.

import { Perf } from 'r3f-perf'

function Scene() {
  return (
    <Canvas>
      <Perf position="top-left" />
      {/* your scene */}
    </Canvas>
  )
}
Enter fullscreen mode Exit fullscreen mode

Watch the calls counter. If it's above 100 for a moderate scene, you have a draw call problem. If memory keeps climbing as you navigate, you have a disposal problem. Both can happen simultaneously, which is why the fix often feels like whack-a-mole.

Root cause #1: every mesh is its own draw call

This is the killer for galleries specifically. Each <mesh> you render gets its own draw call to the GPU. Fifty unique meshes with different materials? Fifty (or more) draw calls per frame. The GPU isn't slow at rendering — it's slow at being told what to do, fifty times, sixty times per second.

Here's the naive version most galleries start with:

// Slow: each card creates its own geometry and material
function Gallery({ items }) {
  return items.map((item) => (
    <mesh key={item.id} position={item.position}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={item.color} />
    </mesh>
  ))
}
Enter fullscreen mode Exit fullscreen mode

If every box is the same shape, you're paying for fifty draw calls when you should be paying for one.

Fix #1: use instancing

Three.js has InstancedMesh, and R3F exposes it via the <instancedMesh> component. Drei wraps it with a friendlier API in <Instances>:

import { Instances, Instance } from '@react-three/drei'

function Gallery({ items }) {
  return (
    <Instances limit={1000}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial />
      {items.map((item) => (
        // Each Instance shares geometry/material with siblings
        <Instance
          key={item.id}
          position={item.position}
          color={item.color}
        />
      ))}
    </Instances>
  )
}
Enter fullscreen mode Exit fullscreen mode

My friend's scene went from 8 FPS to a locked 60 with this change alone. One draw call instead of fifty.

If your meshes aren't identical, you can still group them. Bucket items by geometry type and create one <Instances> per bucket. Three buckets and one draw call each beats fifty individual draws every time.

Root cause #2: leaked geometries and materials

React's mental model is that unmounting cleans things up. The DOM has a garbage collector for that. The GPU does not.

When you create geometries and materials manually (especially inside useMemo or useLoader), they sit in GPU memory until you call .dispose() on them. R3F handles disposal automatically for declarative JSX, but the moment you write something like this you're on your own:

function BrokenComponent() {
  // Bad: geometry is recreated every render
  // and the old one is never disposed
  const geometry = new THREE.BufferGeometry()
  geometry.setAttribute('position', /* ... */)

  return <mesh geometry={geometry} />
}
Enter fullscreen mode Exit fullscreen mode

Every state change leaks a fresh BufferGeometry into VRAM. Five minutes of interaction and you're out of memory.

Fix #2: useMemo plus explicit disposal

import { useMemo, useEffect } from 'react'
import * as THREE from 'three'

function FixedComponent({ points }) {
  const geometry = useMemo(() => {
    const g = new THREE.BufferGeometry()
    g.setAttribute('position', new THREE.BufferAttribute(points, 3))
    return g
  }, [points])

  // Dispose when the geometry instance changes or component unmounts
  useEffect(() => () => geometry.dispose(), [geometry])

  return <mesh geometry={geometry} />
}
Enter fullscreen mode Exit fullscreen mode

The useEffect cleanup is the part most people miss. useMemo prevents recreation on every render, but it doesn't free the previous one when the dependency changes. You have to do that yourself.

For textures loaded via useLoader, R3F caches them, so you usually don't need to dispose manually — but if you ever swap textures dynamically, the cached ones stay in memory unless you call useLoader.clear().

Fix #3: don't load what nobody is looking at

Even with perfect instancing and disposal, loading fifty high-poly GLTF files at mount is a bad idea. Lazy-load them as the user scrolls:

import { Suspense, lazy } from 'react'
import { useInView } from 'react-intersection-observer'

function GalleryItem({ url }) {
  // Only mounts the loader when the slot scrolls into view
  const { ref, inView } = useInView({ triggerOnce: false })

  return (
    <group ref={ref}>
      {inView && (
        <Suspense fallback={null}>
          <LoadedModel url={url} />
        </Suspense>
      )}
    </group>
  )
}
Enter fullscreen mode Exit fullscreen mode

Combine this with <Bvh> from drei for fast raycasting on large scenes, and you've covered the three big performance levers.

Prevention checklist

A few habits that have saved me real time on every R3F project I've shipped:

  • Profile first. Never optimize a scene you haven't measured. r3f-perf or the Chrome Performance tab tell you the truth.
  • Reuse geometries and materials. If two meshes use the same box, they should reference the same BufferGeometry.
  • Watch your useFrame callbacks. Anything inside useFrame runs every frame. Don't allocate objects there — reuse vectors declared outside.
  • Set a dpr cap on your Canvas. Retina displays can quietly render at 3x resolution. <Canvas dpr={[1, 2]}> clamps it.
  • Test on actual mid-range hardware. Your M-series Mac is lying to you about how the gallery performs on a five-year-old Windows laptop.

Most R3F performance problems aren't really R3F problems. They're WebGL fundamentals leaking through a friendly React API. Once you know to look for draw call counts and forgotten .dispose() calls, you stop being surprised when a beautiful scene grinds to a halt — and you stop debugging the wrong thing for an afternoon.

Top comments (0)