Optimize Three.js: 4 Key Techniques
Practical tips for improving performance when working with Three.js scenes in real-world projects.
Code can be art. Whether it's in clever syntax, elegant data structures, or refined interactions, there’s beauty only programmers see—and that’s fine.
But code can also create something visually stunning, something everyone can appreciate. This is where tools like Three.js shine. However, Three.js can be heavy, especially when used in a dynamic web page accessed by devices with varying computational power.
If you’re like me, adding multiple Three.js scenes to your site (as I do on didof.dev), you’ll need optimizations. Here are three practical techniques to keep performance in check.
Originally posted on my blog.
Load Scenes Only When Needed
Don’t load a scene if it’s not visible. This applies to any heavy graphical component. The best tool for this is IntersectionObserver
, which detects when an element enters the viewport. Here's how I handle it in SvelteKit:
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let ref: HTMLDivElement;
let download = $state(false);
if (browser)
onMount(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
download = true;
// we need this once only
observer.disconnect();
}
});
// ref has been bound by Svelte since we are in onMount
observer.observe(ref);
return () => observer.disconnect();
});
</script>
<div bind:this={ref}>
{#if download}
<!-- let SvelteKit handle the code splitting -->
{#await import('./three-scene.svelte')}
Loading
{:then module}
<module.default />
{:catch error}
<div>{error}</div>
{/await}
{/if}
</div>
Pause Scenes Out of View
If a scene isn’t visible, stop rendering it. Most tutorials focus on a single fullscreen scene, but for sites with multiple scenes, pausing hidden ones saves resources.
Here’s a snippet using IntersectionObserver
to control a scene’s animation loop:
function tick() {
const elapsedTime = clock.getElapsedTime();
// Update your scene (e.g. set uniforms, move/rotate geometries...)
renderer.render(scene, camera);
}
// Start the rendering
renderer.setAnimationLoop(tick);
Once again, our friend IntersectionObserver
comes to our aid.
let clock: THREE.Clock;
let renderer: THREE.WebGLRenderer;
if (browser)
onMount(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
clock.start();
renderer.setAnimationLoop(tick); // resume
} else {
clock.stop();
renderer.setAnimationLoop(null); // pause
}
});
observer.observe(canvas);
// Scene setup...
return () => {
observer.disconnect();
// Other cleanup...
};
});
Adjust Shader Workload for Viewport Size
Devices with smaller screens are often less powerful. Adapt your shader’s computational workload accordingly. For example, reduce the number of octaves used in a fractal shader based on the viewport width:
<script lang="ts">
import ThreeScene from "./three-scene.svelte";
import { browser } from '$app/environment';
const octaves = browser ? (window.innerWidth <= 680 ? 2 : 4) : 1
</script>
<ThreeScene {octaves} />
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uOctaves: new Three.Uniform(octaves) // coming as $prop
}
});
uniform float uOctaves;
for(float i = 0.0; i <= uOctaves; i++)
{
elevation += simplexNoise2d(warpedPosition * uPositionFrequency * pow(2.0, i)) / pow(2.0, i + 1.0);
}
This approach dynamically balances performance and visual quality.
Let the Browser Handle Clean up
Here’s where things get tricky. Three.js doesn’t automatically clean up memory, and you need to manually track and dispose of objects like geometries, textures, and materials. If you skip this, memory usage grows every time you navigate away and back, eventually crashing the browser.
Let me share what I observed on my homepage:
Initial memory usage: 22.4MB
After soft-navigation to another page: 28.6MB (even though that page was static HTML).
After repeated navigation back and forth: Memory usage kept climbing until the browser crashed.
Why? Because Three.js objects weren’t being disposed of properly. And despite extensive research, I couldn’t find a reliable way to fully clean up memory in modern frameworks.
Here’s the simplest solution I found: force a hard-reload when leaving pages with Three.js scenes. A hard-reload lets the browser:
Create a new page context.
Perform garbage collection on the old page (leaving cleanup to the browser).
In SvelteKit
, this is easy with data-sveltekit-reload. Just enable it for pages with scenes:
export function load() {
return {
sveltekitReload: true
}
}
For navigation links, pass this value dynamically:
<script lang="ts">
import { page } from '$app/stores';
</script>
<a href="/docs" data-sveltekit-reload={$page.data.sveltekitReload}>Docs</a>
If you use a generic
<Link>
component, you only need to implement this once.
This approach isn’t perfect—it disables smooth client-side routing for specific pages—but it keeps memory in check and prevents crashes. For me, that trade-off is worth it.
Final Thoughts
These optimizations have worked well for me, but the question remains: how do we properly clean up Three.js objects in modern frameworks? If you’ve found a reliable solution, I’d love to hear from you!