Performance problems in a 3D website fall into three segments, and the measurements and fixes for one do not carry over to another. Loading is how long the wait lasts before the product appears and can be operated. Memory is whether a phone can hold the scene without crashing. Per-frame cost is whether rotating and zooming stay smooth. Lumping all three into 'it is slow' makes it impossible to tell what to change.
What Core Web Vitals Can and Cannot Measure
LCP is the time to paint the largest image, text block, or video in the first screen, counted from the moment navigation starts; 2.5 seconds or less is good (Walton et al., 2019). INP summarizes the interaction latency of all clicks, taps, and key presses during a visit, takes the worst interaction while excluding outliers, and counts 200 milliseconds or less as good and 500 milliseconds or more as poor (Wagner et al., 2022).
CLS measures unexpected layout shift; 0.1 or less is good and above 0.25 is poor (Mihajlija et al., web.dev). By these definitions, none of the three covers when the product first appears inside the canvas, when it first becomes usable, or the per-frame time while a model rotates, so those need separate measurement points.
The WebGL recommendations in the MDN documentation (MDN Web Docs, 2026) are likewise organized in three parts: reduce draw calls, manage texture memory, and compile all shaders before linking. Draw calls and shader compilation fall under per-frame cost, and textures fall under memory and loading. The documentation gives principles only and no measured numbers.

Separate Timing from Cost, Then Find the Bottleneck
Two very different problems in a 3D scene both show up as stutter. A timing problem is an animation that does not connect or a transition that pauses. A cost problem is every frame doing too much work. If the stutter is constant, measure frames per second, shader recompilations, and GPU time first; if it appears only during transitions, look at animation timing.
A common misdiagnosis of continuous stutter is adjusting animation timing again and again to no effect. The real causes can be two: the screen is locked into continuous rendering although the content is static, and interface annotations are rebuilt in batches at short intervals, so that when a third-party line component unmounts, it also releases materials still in use. Shaders then recompile several times per second. Diagnosis switched to intercepting WebGL calls: the number of clear calls approximates the frame count, and the number of linkProgram calls is the number of shader recompilations. drawElements runs in the hundreds per frame, which makes it a poor indicator.
The scene of a product configurator has about 60 draw calls and about 1 million triangles. Sweeping the pixel ratio across seven values and fitting a regression splits the per-frame cost into a fixed part and a part that grows with pixels. On desktop a frame costs about 4 ms, of which about 60% is fixed; in the mobile configuration a frame costs about 2.5 ms, of which about 80% is fixed. Fixed cost does not shrink when resolution drops.
- Confirm the symptomConstant stutter, or only during transitions
- Measure costFrames per second, shader recompilations, GPU time
- Split the cost structureFixed cost versus cost that grows with pixels
- Stutter only during transitions or switchesCheck animation timingWake-up duration, camera animation, frame catch-up order
- Constant stutter, shaders recompile at a regular rhythmFind materials that are repeatedly created and releasedDo not rebuild interface annotations in batches
- Constant stutter, fixed cost dominatesGeometry: reduce and step down detail. Draw calls: merge drawsLower-detail models while dragging, texture atlases for static parts
- Constant stutter, cost grows with pixels and layersCut layers per pixel and material workLowering resolution alone may not help
Set Budgets Early and Convert Each Item
Budgets are settled at the start of a project and allocated item by item, like a spreadsheet. In a property-showroom website, a Chromium emulation of a phone used about 2.6 GB of memory. The target came in two stages: first about 0.9 GB, then tightening toward a lower peak for the whole flow, and the trees, night scene, and progressive sharpening added later all had to fit within the same budget. The item-by-item conversion:
- Decoder workers shut down after each batch loads, and the CPU-side copies of textures and geometry are released once uploaded to the GPU.
- On phones, the environment map, shadows, water reflection, and panorama cache are each stepped down.
- Texture tiers are decided by the owner: the 1024 tier on phones, and a tier chosen by device on desktop.
- On phones, geometry is split into chunks of no more than 65,536 vertices with 16-bit indices, and positions and normals are stored as quantized integers.
Features that are harmless alone but fail together can only be found by measurement. The night scene and the trees each added less than 5 MB at every step of the flow, yet turning both on at once jumped about 40 MB per step, because the cause is the process's memory high-water mark, not the sum of the two. The decision was to keep the trees on phones and leave the night-time city to desktop, which the owner accepted. A lightweight mobile night scene that lights only the towers was built later, and the peak increase over a long flow was about zero.
After the first stage, the memory high point of the whole flow was about 910 to 960 MB; after the second stage it fell to about 510 to 710 MB, with a single peak of about 750 MB and measurement noise of about ±40 MB. These numbers come from desktop Chromium emulating a phone. The budget table tracks every item instead of a single total.
First-screen download size is a second budget. The 3D configurator in the LIDROP case was inventoried in rounds with a fixed method: after each build, a headless browser reads every resource request and classifies the requests by file extension, and 3D files make up about 80%. Each round touches only bytes that are never used at runtime: round one took the first-screen download from about 57 MB to about 43 MB, and round two to about 37 MB. A comparison for optimized models appears in before-and-after numbers for 3D model optimization.
- Before the inventoryabout 57 MBFirst-screen download
- Round oneabout 14 MBThree adjustments, all to data never used at runtime
- After round oneabout 43 MBFirst-screen download
- Round twoabout 6 MBThree adjustments on the asset side
- After round twoabout 37 MBFirst-screen download
LIDROP 3D configurator, same model throughout; after each build, a headless browser reads the resource requests and classifies the requests by file extension
Round three started from a different build. Embedded base-color images that are always replaced at runtime were shrunk to 4×4, the seat texture was lowered to 1024, and descriptive data was deferred, taking the size from about 36.5 MB to about 32.5 MB. The starting build differed from the first two rounds, so the three do not join into one curve. Whenever a part is added, the first check is whether its embedded texture gets replaced at runtime; if it does, shrink it.
Two further items would change the loading architecture: splitting files per model would save about 3.4 MB, and sharing one texture across four files would save about 2.5 MB.

Choose the Fix by Bottleneck
The same symptom calls for different fixes depending on the bottleneck. Each row of the table is a measured result, not a general rule.
| Bottleneck | What works | What fails or has a cost |
|---|---|---|
| Geometry and draw calls | Stand in a simplified-geometry proxy for the mirror pass and use lower-detail models while dragging; merge draws and use texture atlases | Lowering resolution alone: fixed cost does not fall |
| Fill rate and material work | Reduce the layers per pixel; render lighting that can be precomputed offline into textures | Permanently lowering resolution: the image turns soft and gets no faster |
| Repeated shader recompilation | Avoid rebuilding interface annotations in batches and do not release materials still in use | Adjusting animation timing, which does not fix continuous stutter |
| One-time cost | Build in slices under a per-frame budget of at most 2 ms, nearest to the camera first | Building more than a hundred objects at once, stalling a single frame for about 50 to 100 ms |
The geometry-bound example used a GPU timer query to measure the GPU time of the main pass and the mirror pass separately: the mirror pass took about 1.1 to 1.2 ms, and a simplified-geometry proxy brought it below 0.2 ms (headless desktop with a phone-sized window; only interleaved ratios were trusted). In the offline-rendering example, the environment was rendered ahead of time into a panorama and a near-ground texture, and the two GPU passes on the phone tier fell from about 8.1 ms to about 4.7 ms. The cost is that reflections of the product in ground puddles are off by default, and trade-offs of this kind are listed for the client to decide before work starts.
The fill-bound example is a Gaussian-splatting (3DGS) scan scene: the average overlap measured about 180 layers against a healthy range of about 5 to 15, so reducing the number of Gaussians worked while lowering resolution alone made no difference in speed. The example of throttling updates while the camera moves comes from an event-map website: on an emulated phone, the reflection update dropped from 60 to 30 times per second, and per-frame JavaScript time during dragging fell from about 8.5 ms to about 6.5 ms.
Lower Resolution Works Only When Pixels Are the Bottleneck
Lowering resolution for smoothness is a common move; whether it works depends on the bottleneck, and every switch has a cost of its own. In one measured configurator, pixels dropped to about 36% of the original, yet frame time fell by only about 25% on desktop and about 15% in the mobile configuration, because fixed cost dominates. Each resolution change also reallocates the drawing buffer, so one drop and one restore costs about 20 ms on desktop and about 10 ms on mobile; break-even on desktop needs a continuous drag of about 0.4 seconds, and on mobile it never breaks even. That configurator therefore lowers resolution only while the loading screen hides the scene, and never during interaction. In another product the bottleneck was fragment shading of the material, so pixels were the cost, and on phones the drag therefore uses a fixed lower pixel ratio.
The Right Compression Format Flips with Network Speed
The Khronos EXT_meshopt_compression extension describes another geometry compression format built around fast decoding (Kapoulkine et al., Khronos). Textures in KTX2 with Basis Universal are transcoded by the engine into the block-compressed format the platform supports (Callow et al., 2020). Neither specification quantifies the savings, so how much is saved has to be measured.
Measured from the modelers' original files, converting to meshopt made files larger by 1.5 to 2.3 times on average, so the encoding has to be recomputed around first-visit experience. For one wheel model, draco downloads about 3.8 MB and decodes in about 1.2 seconds, while meshopt downloads about 6.4 MB and decodes in about 0.8 seconds. Counting download plus decode, draco is faster at or below about 46 Mbps and meshopt is faster above it, as at 100 Mbps. These numbers show that a crossover exists; the actual crossover point varies with device and network.
- about 26 MBmeshoptabout 52 MB
- about 16 MBPure dracoabout 97 MB
- about 21 MBQuantize, then dracoChosenabout 52 MB
LIDROP, total of the 7 first-screen model files; a different measure from whole-page first-screen download
Pure draco gives the smallest files, but it decodes to full-precision floating-point data, and the geometry on the GPU reaches nearly 97 MB; quantizing first and then applying draco gets both, and became the default. Comparisons must start from the original files: converting a file that is already meshopt back to draco makes it 71% larger.
Loading Order: Show First, Add Fidelity Later
Loading is often slow not because the files are large but because the waiting is ordered wrongly. A common pattern keeps the loading screen until the whole product, about 80 to 100 MB, has loaded. The fix is to change the reveal condition so that two main bodies and six categories of key parts are each ready, with non-critical parts filled in after the reveal. The page then prefetches the key GLB files and the decoders from index.html, because the network sat idle while JavaScript was still parsing and the parts-list API had not yet responded. At 20 Mbps, the time for the decoders to arrive fell from about 12 to 13 seconds to about 6.5 to 7.4 seconds. Prefetching has two lines not to cross:
- as=fetch must be paired with crossorigin=anonymous; without the pairing the file downloads twice, which is worse than no prefetch.
- If the prefetch list exists in two copies (the HTML and a code constant), letting the copies drift apart only wastes downloads, and no error is ever raised.
When the reveal waits for the whole first-screen set, the time on a saturated line is nearly bytes divided by bandwidth. Under a 20 Mbps throttle, for example, a first screen of about 35 MB keeps the loading screen up for about 15.9 seconds; at about 2.5 MB/s the download takes about 14 seconds. After the request order is adjusted, the reveal comes at about 15.8 seconds, a difference within noise, so the only effective lever is downloading less before the reveal.
For a product with demanding visual fidelity, loading is split into two stages. Each color first loads a lightweight version holding geometry, a 2048 base color, and 1024 normal and material-parameter textures, about 10.7 MB, and the loading screen waits only for that; the high-resolution textures are fetched in parallel in the background, transcoded, and swapped in. An 8K texture uploads at about 89 MB at once and stalls a desktop for about 69 ms, so it is uploaded in slices of about 1 MB with at most 4 ms per frame.
Measurement used a local production build, cold cache, throttling, the median of three runs per cell, and headless with a real GPU. A phone on a 10 Mbps connection saw the product appear after about 12 seconds instead of about 24, the first-visit total on desktop grew by about 4 MB, and the screenshot after the upgrade is pixel-identical to the one from loading everything at once. A smaller texture saved as WebP made the product appear only about 0.35 seconds sooner, while temporary GPU use rose from about 10 MB to about 35 MB and the fine normal-map detail disappeared, so it was rejected.
Whatever cannot be seen before the user can act should never block the first interaction. On an event-map website over slow phone networks, the models of three islands, about 2.5 MB in all, load only after the page is interactive, and each island first enters as a shell holding its place. Before that point, images that appear only afterward are not downloaded, and the countdown page does not load the 3D JavaScript. On an emulated phone with slow 4G, time to interactive fell from about 60 seconds to about 48 seconds, then to about 41 seconds after the second batch of changes.
Before interactive
- Main scene and key partsThe reveal condition waits only for these
- Island shellsPositions come from constants; a placeholder holds the place until the model arrives
- Images on the first screenLayout reserves space by width and height
After interactive
- Models of three islandsAbout 2.5 MB in all, swapped in and faded in on arrival
- Images seen only laterNo URL is given before interactive
- Pages that need no 3DThe countdown page loads no 3D JavaScript
Never block the first interactionWhatever cannot be seen is deferred and added one piece at a time once the user can act
An event-map website, adjustments measured on an emulated phone with slow 4G
Acceptance Means Reproducible Numbers
Acceptance rests on numbers that can be reproduced, not on a screen recording. Each cell states its conditions: the target device, the network throttle, the cache state, and how many runs the median is taken over. When the machine carries other load, a single run can drift by about 2 ms, so only ratios from interleaved runs are trusted. Acceptance has four columns:
- Loading: when the product first appears and the first-screen download size, one cell for each device and network speed.
- Memory: the high point and peak over the whole flow, compared on the same build with only a feature flag switched.
- Per-frame cost: frame time while dragging and zooming, and the number of shader recompilations.
- Fidelity: the screenshot after the upgrade compared pixel by pixel against the baseline.
Real-device verification is a cell of its own. Most numbers here come from emulation or headless measurement in a desktop environment, and any number not marked as a real device is not one. Where measurement error comes from, and which methods hold up, is covered in measuring Web 3D performance. The full scope of 3D website development is described in the 3D website development service.
The same method applied to a real buying path appears in the path of a 3D property showroom and previewing a custom sign before payment.
How tiered loading works on a 3D world map is covered in the front end of an online fair.
When This Approach Does Not Fit
- The content has no interactive need: video is enough.
- The target devices are old and the budget does not allow tiers: giving different devices different quality is what makes a budget table workable.
- The decision maker wants only a striking picture and will not accept trade-offs for phones: every cell of the budget table is a trade-off, and someone has to decide.
Preparation Before an Evaluation
Four items are needed before an evaluation starts:
- Target device list: how old the oldest supported model is determines how the budget is set.
- Assets that must appear on the first screen: these set the baseline of the loading budget.
- Existing model files: used to re-measure from the originals.
- URL of the current version of the page: used to measure the first baseline.
Once these items are ready, inquire about a 3D website performance evaluation.




