blog
3d

Optimize Three.js: 4 Key Techniques

Practical tips for improving performance when working with Three.js scenes in real-world projects.


Written on November 29, 2024.

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.

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:

setup the scene
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.

pause when out of viewport
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:

from the browser...
<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} />
...through three.js...
const material = new THREE.ShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms: {
        uOctaves: new Three.Uniform(octaves) // coming as $prop
    }
});
...finally, in the shader.
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

homepage allocates 22 megabytes on the heap

After soft-navigation to another page: 28.6MB (even though that page was static HTML).

docs after homepage allocates 28 megabytes

After repeated navigation back and forth: Memory usage kept climbing until the browser crashed.

soft-navigating-without cleanup will result in crash

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:

  1. Create a new page context.
  2. 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:

homepage's +server.page.ts
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!