Troubleshooting a Memory Leak Caused by THREE.Cache.enabled

profile image

I looked into how THREE.Cache, enabled to optimize GLTFLoader performance, ended up causing a memory leak in a dynamic URL environment.

This post has been translated by Jetbrains's Coding Agent Junie junie logoPlease let me know if there are any mistranslations!

While developing a feature to render 3D models with Three.js, a bug report came in: “On mobile, after viewing a few models, the page suddenly refreshes.”

At first, I suspected a simple network issue or browser compatibility problem, but after reproducing it, there was a clear pattern. On iPhone Safari, loading about three to four different 3D models in a row repeatedly triggered a forced page refresh by the browser.

On mobile browsers, one of the most common reasons for this kind of forced refresh is running out of memory. iOS Safari ruthlessly kills the page when memory exceeds a certain threshold. So I decided to focus on the possibility of a memory leak.

Problem

Using the Memory tab in Chrome DevTools, I took several heap snapshots to track changes in memory usage. As expected, every time a 3D model was loaded, JSArrayBufferData kept accumulating by the size of the corresponding GLB file. If you load a 10 MB model five times, about 50 MB would remain in memory.

jsarraybuffer-memory.png

I had already implemented diligent cleanup logic in a useEffect cleanup function, calling geometry.dispose(), material.dispose(), and texture.dispose() across the board, and even assigning null explicitly. Nevertheless, memory usage wouldn’t go down.

Cause

The culprit was a single line I had configured globally early on for performance optimization.

typescript
THREE.Cache.enabled = true;

This setting makes GLTFLoader cache files in memory to save network cost when requesting the same URL again. In general, this is a good optimization, but a specific characteristic of the project turned it into a problem.

For security reasons, the 3D model files were accessed via pre-signed URLs. Once downloaded, the URL was immediately expired and replaced with a new one. In other words, even for the same model, every access had a completely different URL. As a result, the Three.js cache treated them as “different files,” kept piling them up, and never reused them.

Fix

Once I identified the cause, the fix was straightforward: in the useEffect cleanup function, in addition to the existing dispose logic, explicitly remove entries from the cache as well.

typescript
useEffect(() => {
    return () => {
      // ...
      THREE.Cache.remove(fbxUrl);
      THREE.Cache.remove(glbUrl);
    };
  }, []);

After the change, profiling memory again showed that JSArrayBufferData was now cleaned up properly when navigating between pages. Even on mobile, loading more than 10 models consecutively worked stably without forced refreshes.

THREE.Cache.remove vs dispose

THREE.Cache.remove deals with memory at the file cache level. In contrast, dispose releases resources of Three.js objects at the GPU memory level. They address different layers.

  • Cache.remove: managed at the Three.js file cache level (the original file data loaded over the network)
  • dispose: managed at the GPU memory level (WebGL resources such as geometry, material, texture)

For a complete cleanup, you need both.

Wrap-up

I learned that a caching feature enabled for performance, in special circumstances, can actually be the source of a memory leak. It also reminded me to consider in advance whether enabling caching features like THREE.Cache.enabled is truly necessary and what side effects might arise given the characteristics of the project.


References