Even a well-compressed product model is a few MB, and for a second or two on a normal connection the user is looking at an empty canvas. That gap is where people decide the page is broken and leave. A configurator needs a loading state as much as it needs the model.
GLTFLoader gives you progress for free
The loader reports bytes as they arrive:
loader.load( '/models/pool.glb', (gltf) => { scene.add(gltf.scene); hideLoader() }, (event) => { if (event.lengthComputable) { const pct = (event.loaded / event.total) * 100 setProgress(pct) } }, (err) => showError() ) A real percentage, a real bar, and the user knows something is happening.
The gotcha: total is often zero
event.total is only set when the server sends a Content-Length header. With gzip transfer encoding or chunked responses, it frequently comes back 0, and your percentage is Infinity or NaN. That is why so many Three.js loaders show a bar stuck at zero.
Handle both cases: use lengthComputable to decide between a real bar and an indeterminate spinner.
(event) => { if (event.lengthComputable && event.total > 0) { setProgress((event.loaded / event.total) * 100) } else { showIndeterminate() // bytes are flowing, size unknown } } If you control the server, send Content-Length (do not gzip already-compressed .glb, it does not help and it hides the size). Then you get a real bar everywhere.
Fill the canvas before the model arrives
The bar is the minimum. Better: show the product's poster image, or a low-poly placeholder, or the configurator UI in a skeleton state, so the first paint is never empty. The model fades in when ready. The pool configurator shows its UI and a progress state immediately, so the few MB download never looks like a stall.
Perceived performance is performance. A model that loads in 1.5 seconds with nothing on screen feels slower than one that loads in 2 seconds with a bar and a placeholder.
I am Dominik Groński / GroDev, a studio in Poznan, Poland building custom 3D configurators and Laravel back-ends for manufacturers.



