I asked for two small 3D objects - a sun and a moon to sit in the sky behind a planet in my React Native app. What arrived was sun.glb at 4.8MB and moon.glb at 3.1MB. For comparison, every other 3D object already shipping in that app added up to 2.9MB total. Two decorations were about to triple the asset payload of the entire product.
The obvious guess is that the models are too detailed. That guess was wrong, and being wrong about it in the usual way is why this is worth writing down: nearly all of the GLB file size was textures, and my renderer throws textures away before it draws anything. The geometry - the part I actually use - was 1% of the file.
Where GLB file size actually goes
Before guessing, open the file. A .glb is not an opaque blob, and you do not need a library to see what its GLB file size is made of. The format is a 12-byte header, then a JSON chunk describing the whole scene, then a binary chunk holding the raw buffers. The JSON chunk lists every mesh, every accessor, and every embedded image, which means you can answer “what is in this file and what does it weigh” with readFileSync and nothing else. No loader, no CLI, no install.
import { readFileSync } from 'node:fs';
export function inspect(path) {
const buf = readFileSync(path);
if (buf.toString('ascii', 0, 4) !== 'glTF') throw new Error('not a GLB');
const jsonLen = buf.readUInt32LE(12); // length of chunk 0
const json = JSON.parse(buf.toString('utf-8', 20, 20 + jsonLen));
const triangles = (json.meshes ?? [])
.flatMap((m) => m.primitives)
.reduce((sum, p) => {
const acc = json.accessors[p.indices ?? p.attributes.POSITION];
return sum + Math.floor(acc.count / 3);
}, 0);
// Embedded images live in bufferViews. Their combined length IS the texture cost.
const textureBytes = (json.images ?? []).reduce(
(sum, im) => sum + (im.bufferView != null ? json.bufferViews[im.bufferView].byteLength : 0),
0,
);
return {
bytes: buf.length,
meshes: (json.meshes ?? []).length,
triangles,
images: (json.images ?? []).length,
textureBytes,
texturePct: `${((textureBytes / buf.length) * 100).toFixed(1)}%`,
attributes: [...new Set((json.meshes ?? [])
.flatMap((m) => m.primitives)
.flatMap((p) => Object.keys(p.attributes)))],
};
}
Run that on the two originals and the answer is immediate:
| bytes | triangles | images | texture share | |
|---|---|---|---|---|
sun.glb | 5,056,996 | 1,038 | 4 | 98.7% |
moon.glb | 3,277,972 | 1,050 | 4 | 98.3% |
Four images each, every one of them 2048x2048: base color, normal, metallic-roughness, emissive. On the sun, the normal map alone was 2,153KB - forty times the size of the entire geometry.
The geometry was never the problem. 1,038 triangles for a decorative object in the sky is exactly right. The modeler delivered a correct model. They exported a complete PBR material set because that is what a finished asset looks like, and nobody had told them the destination throws materials away.
Why my renderer discards every one of those textures
The objects in this app change color by time of day. The sun is warm at dawn and near-white at noon; the moon is silver at night. If the color lived in a baked texture, I would need one texture per time band per object, and they would have to agree with the scene lighting - which is set in code. So the pipeline was built the other way around: the GLB contributes shape, the code contributes appearance.
The loader reflects that. It takes the geometry off the first mesh and drops the rest on the floor:
// The GLB is a shape supplier. Material, color, and textures are never read.
const geometry = await loadAssetGeometry(SKY_ASSETS.sun);
<mesh geometry={geometry}>
<meshStandardMaterial
color={daylight.skyBody.color} // set per time-of-day segment, in code
emissiveIntensity={daylight.emissive}
roughness={0.55}
metalness={0.05}
/>
</mesh>
Given that, those four 2048px maps were not “heavy assets.” They were bytes that would be parsed, uploaded, and then never sampled. On a phone that is worse than wasted disk: it is decode time and peak memory during startup, spent on pixels no frame will ever contain.
So I stripped them. Keep POSITION, NORMAL, and the index buffer; delete images, textures, materials, and the sampler set.
| sun | bytes | triangles | images |
|---|---|---|---|
| as delivered | 5,056,996 | 1,038 | 4 |
| shipping today | 50,424 | 1,038 | 0 |
| -99.0% | unchanged |
Both of those files are still on disk, so those are numbers I re-measured today rather than remembered. The triangle count is identical on both sides. Nothing about the shape was lost, because nothing about the shape was removed. This was not compression or decimation - there is no quality tradeoff to weigh here. It was deleting a payload the program never opens.
The moon took the same treatment at the time, from the same 3.1MB starting point. I am not going to quote its stripped size, because that file no longer exists: the moon was remodeled soon after, and the replacement is what shipped. Which is where the second problem showed up.
The second one, which hid behind the first
The moon was later remodeled and re-delivered as a crescent. That file came in at 133,868 bytes with zero images - the texture lesson had landed. It looked clean.
It was not. The inspector’s attributes line still read POSITION, NORMAL, TEXCOORD_0. UV coordinates: two floats per vertex telling the renderer where on a texture each point maps. There were no textures anymore. The coordinates pointed at nothing and were still being shipped and parsed on every launch.
Removing that one attribute: 133,868 → 105,224 bytes, a 21.4% cut with no visual change whatsoever.
I want to be precise about why this one is the more interesting failure. The 4.8MB file announced itself - you cannot miss 4.8MB. The 134KB file looked fine. It was already 97% smaller than the thing I had complained about, it had no textures, and the number was small enough that no alarm would ever fire. It only surfaced because I ran the same inspector on it out of habit rather than suspicion. A fixed check catches the quiet version of a problem; remembering to look does not.
The notification bug in the same app had an identical shape: no error, just a silently wrong result.
The rule I actually took away
My first framing was “3D assets are heavy, so keep polygon counts down.” That framing would have led me to ask for a lower-poly sun - which would have made the model worse and saved roughly nothing, since the geometry was 1% of the file.
The real rule is narrower and more useful: an asset spec has to state what the consuming pipeline reads, not just what the object should look like. Mine said the format, the mesh count, the polygon budget, and the orientation. It never said “materials and textures are discarded; ship geometry only.” That omission was mine, not the modeler’s, and it cost 7.9MB before anyone noticed.
The spec now says it in the first line, and the loader file says it again next to the code that does the discarding. Both places, because the person reading one is usually not reading the other.
If you have GLB files in a mobile bundle, the check is a two-minute one. Run the inspector above over your asset directory and read two columns: texture share, and the attribute list. A high texture share is only a problem if your renderer ignores materials - so answer that question first, honestly, by reading your loader rather than assuming. TEXCOORD_0 with zero images is dead weight in every case.
node glb-info.mjs assets/**/*.glb
Measured on 2026-08-11, with three.js 0.181.2, @react-three/fiber 9.6.1, Expo SDK 56, on Node 24.2.0. The file structure this relies on is the glTF 2.0 specification - the 12-byte header and chunk layout have been stable since 2017, so the inspector is not version-sensitive. What is version-sensitive is the claim that materials get discarded: that is true of my pipeline. Go read yours before you delete anything.