Three.js From Zero · Article s13-06

R3F Suspense & Progressive Loading

R3F Suspense & Progressive Loading is Article s13-06 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS13-06 · R3F Mastery

Season 13 · Article 06 · R3F Mastery

The difference between an app that feels broken for 8 seconds and an app that feels fast at 4 seconds is how you handle loading. Suspense, splash screens, LOD swaps, useGLTF.preload — perceived performance.

Code-walkthrough article

Patterns that work in any R3F project. Pair with drei's <Loader> helper for instant wins.

The four loading patterns

  1. Suspense fallback — show something while assets stream.
  2. Preload — start loading before the user navigates to a section.
  3. LOD swap — load low-res first, swap to high-res when ready.
  4. Skeleton 3D — render a placeholder mesh that matches the final shape.

Suspense + drei's Loader

import { Suspense } from 'react';
import { Loader, useGLTF } from '@react-three/drei';

function Scene() {
  const { scene } = useGLTF('/big-model.glb');
  return <primitive object={scene} />;
}

export default function App() {
  return (
    <>
      <Canvas>
        <Suspense fallback={null}>
          <Scene />
        </Suspense>
      </Canvas>
      <Loader />   {/* drei's branded loader with progress bar */}
    </>
  );
}

<Loader> reads from drei's internal progress tracker. It auto-shows during any suspending useGLTF/useTexture call and hides when done. Free, branded, customizable via props.

Preload — start before mount

useGLTF.preload('/duck.glb');
useTexture.preload('/skybox.hdr');

// Or from app startup, anywhere in your code
function preloadAll() {
  useGLTF.preload('/level1.glb');
  useGLTF.preload('/level2.glb');
  useTexture.preload(['/grass.jpg', '/stone.jpg']);
}

Preload kicks off the network request immediately. By the time React mounts the component that calls useGLTF, the cache is warm and the suspense fires for milliseconds instead of seconds.

Custom in-canvas fallback

function LoadingPlaceholder() {
  return (
    <mesh>
      <boxGeometry args={[2, 2, 2]} />
      <meshStandardMaterial color="hotpink" wireframe />
    </mesh>
  );
}

<Suspense fallback={<LoadingPlaceholder />}>
  <ActualModel />
</Suspense>

Pass a 3D element as the fallback. While the model loads, a wireframe box occupies its slot. Scene composition remains stable — no layout shift in 3D.

LOD: low-res first, high-res later

function Model() {
  const lowGltf = useGLTF('/duck-low.glb');     // 50KB
  return (
    <Suspense fallback={<primitive object={lowGltf.scene} />}>
      <HighRes />
    </Suspense>
  );
}

function HighRes() {
  const { scene } = useGLTF('/duck-high.glb');  // 5MB
  return <primitive object={scene} />;
}

The low-res shows immediately (already loaded), and the high-res streams in via Suspense. When ready, React swaps them — usually invisibly, since both occupy the same space.

Skeleton 3D with progress

function Model() {
  const { progress } = useProgress();   // drei hook
  if (progress < 100) {
    return <Skeleton progress={progress} />;
  }
  return <ActualModel />;
}

useProgress from drei gives total loading state across all suspended components. Build a custom placeholder that grows/animates with progress — feels much more "alive" than a spinner.

Common first-time pitfalls

"Suspense flickers on each navigation." You're not preloading. The hot cache cures it.
"<Loader> never goes away." Something is suspending and never resolving — usually a useTexture with a wrong path. Check DevTools network for 404s.
"Low-res LOD never gets swapped." React's Suspense renders the fallback as a sibling, not a swap. If both low and high render simultaneously, you'll see flicker; use the inner-Suspense pattern above instead.

Exercises

  1. Preload on hover. Hover a button "View Model" → kick off useGLTF.preload immediately. Click → useGLTF in the component, which finds the cache warm. Sub-200ms perceived load.
  2. Progress-driven splash. Use drei's useProgress to drive a CSS overlay's opacity. Fade-out when progress hits 100.
  3. Conditional asset paths. Detect mobile (window.innerWidth < 800), useGLTF a smaller model. Same scene, half the bandwidth on mobile.