Why a static Three.js scene still cooks your phone, and the dirty-flag fix
So I have a bunch of small games on my site. Ludo, tic-tac-toe, carrom, rock-paper-scissors. Nothing fancy, the kind of thing you'd think runs on a potato. They're built with Three.js, which is the standard library for drawing 3D graphics in a web page. I was testing them on my iPhone and the phone got genuinely hot. Hot to hold. And here's the part that bugged me: I wasn't even doing anything. I was just sitting on the Ludo board waiting for my turn. Nothing on screen was moving.
My first instinct was the lazy one, and I want to call it out because I bet it's yours too: maybe Three.js is just too heavy for these little games, maybe I should rip it out and draw everything with plain Canvas. That instinct is completely backwards, and figuring out why taught me something about how the GPU actually spends its time that I wish I'd known years ago.
A still frame is not a free frame
Here's the render loop every Three.js tutorial hands you. A render loop is just a function that draws your scene over and over so it looks alive:
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
Two things are happening here. requestAnimationFrame (people shorten it to rAF) is a browser function that means "run this again right before you paint the next frame." Browsers repaint the screen about 60 times a second, so anything you hand to rAF runs about 60 times a second too. And renderer.render(...) is the line that actually tells the GPU to draw the whole scene: clear the screen, then redraw every single object on it.
So this loop tells the GPU to repaint everything, 60 times a second, forever, whether or not anything changed. And a GPU pinned at full tilt is exactly what is heating up the phone. That right there is the whole problem.
The GPU has no idea your scene is "basically static." Every single call clears the framebuffer and redraws every object from scratch. A motionless Ludo board at 60 frames per second costs exactly as much as a board mid-animation, because it is doing literally the same work, 60 times a second, while you stare at a menu deciding your move.
So the heat was never about the framework. It was about asking the GPU to repaint an unchanging picture 60 times a second for no reason at all. Here, watch the difference, and tap "Make a move" on the cold one:
The left phone is the tutorial loop. It climbs to hot and stays there. The right one only draws when something actually happened, so it sits cold until you poke it. Same board, same framework. The only difference is whether the loop bothers to draw when nothing moved.
And no, switching to Canvas2D (the simpler, non-3D way of drawing in a browser) wouldn't have saved me. A 60fps loop redrawing a full-screen canvas heats a phone just as happily, 3D or not. The renderer was never the variable. The loop was.
Render on demand
The fix is embarrassingly simple once you see it. Stop rendering when nothing changed. The trick is a dirty flag: a tiny true/false value you flip to true whenever something actually changes on screen. "Dirty" is the old programming word for "this is out of date, it needs repainting." Then the loop only calls renderer.render() on frames where the flag is set.
let needsRender = true; // draw the first frame
function invalidate() {
needsRender = true; // "the picture is out of date, please redraw it"
}
function animate() {
requestAnimationFrame(animate);
// step whatever is actually animating; these call invalidate() if they changed something
const moving = tweens.update() || particles.update();
if (needsRender || moving) {
renderer.render(scene, camera);
needsRender = false;
}
}
animate();
Now a Ludo board waiting for your input renders zero frames. You call invalidate() from your input handlers, your animations, your resize listener, anywhere a pixel genuinely changes. A turn-based game spends almost its whole life doing nothing, so almost all of that GPU work just evaporates.
Here's the same loop as a flowchart. Flip the switch and watch where the frame goes:
Idle, the frame falls straight through to "sleep" and the GPU does nothing. The moment something's moving, every frame pays for a real render again, which is exactly what you want, only when you want it.
If you use @react-three/fiber (the popular React wrapper around Three.js), you get this for free. Set frameloop="demand" on the <Canvas> and call invalidate() when you change something. Same idea, one prop.
The two settings that quietly double your GPU bill
Render-on-demand kills the wasted frames. But the frames you do draw can each cost about twice what they should, and two renderer defaults are almost always the reason. They bite hardest on phones:
// the expensive defaults
new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio); // often 3 on phones
Two settings, so let me take them one at a time.
The first is antialias. Antialiasing is edge-smoothing: it stops a diagonal line from looking like a staircase of pixels by blending the colors along the edge. Turning it on makes Three.js use MSAA, which stands for multisample antialiasing. Normally the GPU decides a pixel's color from a single sample; with MSAA it takes several samples per pixel and averages them. Smoother edges, but that's literally several times the shading work on every pixel you draw. On a phone you can barely see the difference. You can absolutely feel the heat.
The second is setPixelRatio, and this one is sneakier. devicePixelRatio is how many physical screen pixels your phone packs into one CSS pixel, the unit your layout code measures in. On a modern phone it's 2 or 3. So a canvas that's "390 CSS pixels" wide is really painting up to about 1170 physical pixels across, and the GPU fills every one. And since the canvas scales in both width and height, going from a ratio of 1.5 to 3 doesn't double the work, it roughly quadruples it.
Drag both of these around and watch the cost run away from you:
Here's the thing though: these games run inside a WebView, the embedded browser that a native app uses to show web content. On a small phone screen inside a WebView you almost never need full antialiasing or a pixel ratio of 3. So the fix is to detect that case and dial both back. It roughly halves the GPU load for a result nobody can tell apart:
const lowPower = isWebView || isMobile;
new THREE.WebGLRenderer({ antialias: !lowPower });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, lowPower ? 1.5 : 2));
Two more leaks worth closing
While I was in there, two smaller things were heating up the games that did animate:
- Nothing paused when the app went to the background. Browsers fire a
visibilitychangeevent when your tab or app is hidden or comes back. None of the games listened for it, so the loop kept spinning even after you switched apps. The fix: cancel the animation frame when hidden, resume when visible. Put it in one shared helper, every scene needs it. - Per-frame allocations. One game built a
new THREE.Color()every single frame during an animation, another rebuilt image textures mid-countdown. Allocating inside a 60fps loop churns the garbage collector, and on mobile its pauses show up as both stutter and extra heat. Make those objects once, reuse them.
The takeaway
When a "simple" WebGL or Three.js scene cooks a phone, do not reach for a lighter framework first. Check the loop. The default requestAnimationFrame pattern renders unconditionally, and an unchanging frame is not a cheap frame. Render only when something changed, cap your pixel ratio and antialiasing on constrained devices, and pause when you're hidden. The framework was never the thing burning your battery.