6 React Three Fiber Optimizations for Smooth 3D Games on Mobile
Shipping a React Three Fiber game that runs at 60 FPS on a mid-range Android phone is a different problem than shipping one that runs fine on your dev laptop. These are the six optimizations that actually moved the needle while building 3D 2048 โ what worked, what didn't, and the measured impact for each.
The mobile WebGL reality check
If you're building a 3D web game with React Three Fiber (R3F), your dev experience is probably great. Hot reload is fast, the abstraction over Three.js is clean, and on a modern laptop your scene renders at 144 FPS without breaking a sweat. Then you open it on your phone.
Suddenly the frame rate is 25 FPS, the fans spin up (yes, on a phone), and a player complains it crashes their three-year-old iPhone after a minute. This isn't unusual. Mobile browsers run with tighter memory budgets, fewer GPU resources, and aggressive throttling rules that desktop never enforces. The good news: most R3F mobile problems come from a handful of fixable defaults.
3D 2048 is a small puzzle game (a 27-cell cube of animated tiles, plus a back-view mini-window). Not a graphical monster. But hitting 60 FPS on mid-range hardware still required deliberate effort. Here is what made the biggest difference, in roughly the order you should consider them.
1. Cap the device pixel ratio
This is the single most impactful change for mobile. By default, R3F renders at the device's full pixel ratio. On a modern iPhone, that's 3.0 โ meaning if your canvas is 400ร600 logical pixels, you're actually rendering 1200ร1800 pixels every frame. That's a 9ร workload compared to a 1.0 DPR.
The fix is one prop:
// Before
<Canvas>
...
</Canvas>
// After
<Canvas dpr={[1, 1.5]}>
...
</Canvas>
The dpr tuple is [min, max]. R3F picks a value in that range based on the device โ so high-DPR devices get capped at 1.5 (still sharp), and low-DPR devices stay at 1.0. On the iPhone 12 mini in our testing, this single change took 3D 2048 from ~35 FPS to a solid 60.
How sharp is 1.5x? Sharp enough. Most users won't notice the difference between 1.5 and 3.0 on a 5-inch screen, especially in motion. If you're rendering very fine text or thin lines, bump the max to 2.0. Above that, you're paying for pixels nobody can see.
2. Use frameloop="demand" for turn-based games
R3F defaults to frameloop="always", which means it renders every animation frame (~60 times per second), forever, regardless of whether anything on screen changed. For a continuously animated scene (a flight sim, an idle game with particle effects), that's correct. For a turn-based game like 2048, it's wasted work โ most of the time, the player is just staring at the board thinking about their next move.
// Only render when something actively needs to update
<Canvas frameloop="demand">
...
</Canvas>
In demand mode, R3F renders once on mount, then waits. To trigger a new render you call invalidate():
import { useThree } from '@react-three/fiber'
function MyComponent() {
const invalidate = useThree((s) => s.invalidate)
const handleMove = () => {
// update state...
invalidate() // tell R3F to render the new frame
}
}
The catch: @react-spring/three and OrbitControls both need continuous frames during their respective animations. react-spring calls invalidate internally when a spring is animating, so it works automatically. OrbitControls needs regress mode or manual invalidation during drag โ check the drei docs.
Impact: On a passive board, CPU usage drops from ~40% to ~3% on a mid-range Android. Your battery (and your players' batteries) will thank you.
3. Share geometry across instanced meshes
Naive R3F code creates a new BufferGeometry per JSX mesh:
// This creates 27 separate BoxGeometry instances on a 3ร3ร3 board
{tiles.map((t) => (
<mesh key={t.id}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={t.color} />
</mesh>
))}
Each <boxGeometry> ends up as a separate WebGL buffer on the GPU. With 27 tiles that's 27 buffers when 1 would do โ same shape, same size. For very small scenes the overhead is tolerable. For anything bigger, share:
import { useMemo } from 'react'
import { BoxGeometry } from 'three'
function TileGrid({ tiles }) {
const sharedGeometry = useMemo(() => new BoxGeometry(1, 1, 1), [])
return tiles.map((t) => (
<mesh key={t.id} geometry={sharedGeometry}>
<meshStandardMaterial color={t.color} />
</mesh>
))
}
For larger counts (hundreds of identical meshes), graduate to InstancedMesh via @react-three/drei's Instances component โ one draw call for the entire group.
Impact: For a 27-tile board, sharing geometry saves a few MB of GPU memory and a handful of draw call setup. It's not huge in absolute terms, but the principle generalizes: if you find yourself rendering many identical shapes, share or instance.
4. Cut shadow rendering on mobile
Soft shadows look great. They're also one of the heaviest things you can ask a mobile GPU to do โ every shadow-casting light renders the scene from its perspective into a depth buffer, then samples that buffer per fragment during the main pass.
Default R3F has shadows disabled (you opt in with shadows on <Canvas> and castShadow/receiveShadow on individual objects). But if you've enabled them for desktop polish, gate them for mobile:
const isMobile = /Mobi|Android/i.test(navigator.userAgent)
<Canvas shadows={!isMobile}>
<directionalLight
position={[8, 12, 8]}
intensity={1.2}
castShadow={!isMobile}
/>
</Canvas>
UA sniffing is crude but adequate here โ getting shadows wrong on a tablet is not a catastrophe. For finer control, query navigator.deviceMemory or navigator.hardwareConcurrency and tier accordingly.
Impact: On lower-end Android, removing shadows took our directional-light scene from 22 FPS to 55 FPS. Even higher-end mobile sees a measurable bump because the shadow-map render is pure overhead even when nothing is visibly different.
5. Keep your lighting minimal
Each light in a Three.js scene adds shader complexity. MeshStandardMaterial evaluates physical lighting per fragment, per light, every frame. Two lights is roughly 2ร the per-fragment cost of one.
For most game scenes โ especially stylized ones like a 2048 cube โ you can get away with:
<ambientLight intensity={0.6} />
<directionalLight position={[8, 12, 8]} intensity={1.2} />
That's it. One ambient (no shading cost โ it's a constant added to every fragment) and one directional (shaded). The temptation is to add a fill light or two for "depth" โ resist on mobile, or replace fills with a subtle Environment map:
import { Environment } from '@react-three/drei'
<Environment preset="city" />
Environment maps add image-based lighting that looks rich without adding per-fragment light calculations. The preset HDRIs from drei load lazily and cache. The cost is the initial HDR download (typically 200-800 KB), so for a first-load-sensitive game, skip it or use a smaller custom HDR.
Trade-off note: In 3D 2048 we kept a second, dimmer directional light because the cube has 6 faces and a single key light leaves the back-bottom triplet of faces nearly black. The fix was to make the second light not cast shadows even on desktop โ keep the visual depth, drop the heavy work.
6. Lazy-load the 3D bundle
This one is about perceived performance, not frame rate. Three.js plus R3F plus drei adds roughly 300-400 KB gzipped to your bundle. On a 3G connection that's a 3-second wait before anything renders.
Split it off the critical path:
import { lazy, Suspense } from 'react'
const Game3D = lazy(() => import('./components/Game3D'))
function App() {
return (
<>
<Menu /> {/* renders immediately */}
<Suspense fallback={<Loading />}>
<Game3D /> {/* loads only when needed */}
</Suspense>
</>
)
}
Vite handles the code splitting automatically when it sees the dynamic import. Your menu, your CSS, your fonts โ all rendered while the 3D bundle streams in the background. For 3D 2048 this took time-to-interactive on mobile 3G from ~8 seconds to ~2 seconds. The user sees the menu, picks a mode, and by then the 3D scene is ready.
Pair this with a meaningful loading placeholder โ a low-poly preview or a CSS spinner โ and the perceived speed jumps even more.
What didn't make this list
A few optimizations sound appealing but turned out to be premature for a project this size:
- WebGPU. Cutting-edge and faster in theory. In practice, browser support is patchy on the exact mobile devices you care about. Wait another year.
- Custom shaders. Hand-written GLSL can beat
MeshStandardMaterialon raw speed. But it's a huge maintenance burden for marginal gains in a small game. Worth it for a flagship title, not for a side project. - Aggressive texture compression. Useful if you have textures. 3D 2048 uses solid-color materials, so this never came up. If you're loading PNG textures, look at KTX2 / Basis compression โ but profile first.
- Web Workers for game logic. 2048's move/merge logic runs in microseconds even on slow devices. Workers add complexity and message-passing overhead that isn't worth it until your logic is actually a bottleneck.
How to measure before you optimize
Every number in this article comes from real testing on real devices. Optimizing without measuring is guessing. The minimum kit:
- Chrome DevTools Performance panel โ use the "Frames" track to see actual frame times, not just FPS averages. Spikes matter more than averages for perceived smoothness.
- Remote debugging โ connect your phone to your laptop and run Chrome DevTools against the mobile browser. The desktop profiler is much better than anything mobile has natively.
- R3F Perf overlay โ install
r3f-perfand drop<Perf />in your scene. Real-time draw calls, triangles, GPU time. - An actual mid-range phone โ a 2-3 year old Android in the $200-300 range. If your game runs well there, it'll run well everywhere your players actually live.
Optimize the worst frame, not the average. A game that averages 55 FPS with occasional 5 FPS hitches feels worse than a game that runs steady at 45.
Putting it together
The six optimizations above are roughly ordered by impact-per-effort. If you do nothing else:
- Cap your DPR โ five-second change, biggest single win
- Set
frameloop="demand"if your game allows it โ huge battery and CPU savings - Disable shadows on mobile โ significant FPS bump on lower-end devices
The rest are situational but cumulative. None of them require rewriting your code; they're mostly props on your <Canvas> or one-line refactors. Combined, they took 3D 2048 from "playable on my laptop" to "60 FPS on a 2021 Android with the radio on." That's the bar to aim for.