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.
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.
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.
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