DEV Community

Cover image for Optimizing Three.js: 4 Key Techniques
Francesco Di Donato
Francesco Di Donato

Posted on • Originally published at didof.dev

Optimizing Three.js: 4 Key Techniques

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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...
        };
    });
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

...through three.js...

const material = new THREE.ShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms: {
        uOctaves: new Three.Uniform(octaves) // coming as $prop
    }
});
Enter fullscreen mode Exit fullscreen mode

...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);
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)