How to Save and Reload a Three.js Scene Without Losing Data
If you want to save a Three.js scene and reload it exactly as you left it, the two obvious tools will let you down. Export with GLTFExporter, re-import, and materials flatten. Call Object3D.toJSON() and load it back, and your parametric box is now a frozen vertex soup. This is the years-old three.js Discourse complaint that never fully goes away: "what I see as loaded scene is not what I previously saved."
The fix is not a better exporter. It is to stop serializing the Three.js object tree at all, and serialize your own semantic scene model instead. Here is why, and exactly how.
# Why toJSON() and GLTFExporter lose data
Both built-in paths serialize the render tree — the live THREE.Object3D hierarchy the GPU draws. That tree is a lossy projection of what you actually authored. Once you round-trip through it, the intent is gone.
| Path | What survives | What you lose |
|---|---|---|
Object3D.toJSON() + ObjectLoader | node tree, transforms, standard material props | parametric geometry params, custom userData links, app semantics, non-standard materials |
GLTFExporter + GLTFLoader | render-accurate geometry, glTF-PBR materials, animation clips | your node organization, parametric params, non-PBR shaders, anything not in extras |
| Your own semantic model | everything you defined | nothing — you own the schema |
Two concrete failures show up over and over:
- Parametric geometry gets baked. You created a
BoxGeometry(2, 1, 0.5).toJSON()and glTF both store the result — a buffer of vertex positions and normals. On reload you get triangles, not a box. You can never again change "width" as a parameter, because the parameter no longer exists anywhere in the file. - Materials flatten to a lowest common denominator.
GLTFExportermaps everything onto glTF's metallic-roughness model. AMeshPhysicalMaterialwith clearcoat, a customonBeforeCompileshader patch, or a material whose color you drive from app state all collapse. YouruserDatasurvives only if you manually stuffed it into glTFextras, and even then the loader hands it back as loosely-typed junk.
GLTFExporter is doing its job correctly — glTF is an interchange format, designed to render the same everywhere, not to preserve your editor's intent. It is the wrong tool for a working save file, and the right tool for shipping to Blender, Unity, or a client. Keep both. Just do not use interchange as your source of truth.
# Serialize your own semantic graph instead
The durable approach: treat the Three.js objects as a derived view, and make a normalized object graph your source of truth. Every object is an id mapping to { transform, material params, geometry params, tags, parentId }. That graph is what you save. The Three.js scene is rebuilt from it on load, never the other way around.
This is exactly the store-as-source-of-truth pattern that also keeps React Three Fiber fast — see avoiding the R3F re-render storm for the subscription side of it.
Here is a schema that round-trips losslessly because you decide what it holds:
type Vec3 = [number, number, number]
interface SerializedObject {
id: string
name: string
type: 'box' | 'sphere' | 'cylinder' | 'gltf'
transform: {
position: Vec3
rotation: Vec3 // Euler XYZ in radians
scale: Vec3
}
geometry: Record<string, number> // parametric dims, e.g. { width, height, depth }
material: {
color: string
roughness: number
metalness: number
opacity: number
emissive?: string
map?: string // asset id or URL, never a live THREE.Texture
}
tags: string[]
parentId: string | null
}
interface SerializedScene {
version: 2
objects: SerializedObject[]
environment: { hdri: string; background: string }
}
Note what is not here: no baked buffers, no THREE.Texture handles, no GPU state. Every field is plain JSON you fully control. The map field stores a URL or asset id, not the texture object — the texture is re-resolved on load.
# Save and load
Saving is a projection of your store into the schema. If you keep scene state in a store keyed by id, this is a few lines:
function saveScene(): string {
const { objects, objectOrder, environment } = useSceneStore.getState()
const doc: SerializedScene = {
version: 2,
objects: objectOrder.map((id) => objects[id]),
environment,
}
return JSON.stringify(doc)
}
Loading is the inverse, plus two safety passes:
function loadScene(json: string) {
const doc = JSON.parse(json) as SerializedScene
const objects: Record<string, SerializedObject> = {}
const objectOrder: string[] = []
for (const o of doc.objects) {
objects[o.id] = o
objectOrder.push(o.id)
}
// Drop dangling parent links so nothing orphans on reload
for (const o of Object.values(objects)) {
if (o.parentId && !objects[o.parentId]) o.parentId = null
}
// Replacing the store unmounts old meshes; R3F disposes the
// geometries and materials it created for them.
useSceneStore.setState({
objects,
objectOrder,
environment: doc.environment,
})
}
# Rebuild is just rendering
The elegant part: because the Three.js objects are derived, "reload" means "replace the store and let React rebuild the tree." In R3F you never manually reconstruct objects — you render them from the model, and the parametric geometry comes back as a real parametric geometry, not baked triangles:
function SceneObjectMesh({ id }: { id: string }) {
const obj = useSceneStore((s) => s.objects[id])
if (!obj) return null
const { geometry: g, material: m, transform: t } = obj
return (
<mesh position={t.position} rotation={t.rotation} scale={t.scale}>
{obj.type === 'box' && <boxGeometry args={[g.width, g.height, g.depth]} />}
{obj.type === 'sphere' && <sphereGeometry args={[g.radius, 32, 32]} />}
<meshStandardMaterial
color={m.color}
roughness={m.roughness}
metalness={m.metalness}
transparent={m.opacity < 1}
opacity={m.opacity}
/>
</mesh>
)
}
Change g.width later and the box updates. That is the whole point — the semantic never got flattened.
# Gotchas that bite in production
- Dispose GL resources you own. R3F disposes geometries and materials it created when a mesh unmounts, so replacing the store on load is mostly self-cleaning. But textures loaded via
useLoaderlive in a global cache and are not freed on unmount. Do not naively calltexture.dispose()in a cleanup — the next load of the same URL returns the disposed handle. Instead calluseLoader.clear(THREE.TextureLoader, url)only when you genuinely drop an asset, or keep a refcounted texture registry. - Re-link parents in two passes, by id. Serialize
parentIdas a stable id, never an object reference (references do not survive JSON). On load, validate that every parent exists and null out dangling links before you render, exactly asloadScenedoes above. If you build an actualTHREE.Grouptree, create all nodes first, then attach — a one-pass build fails whenever a child appears before its parent. - Store texture references, not textures. Persist a URL or asset id and a small asset manifest. Never serialize a
data:blob you cannot re-resolve, and never try to JSON-stringify aTHREE.Texture— you will save{}and lose the image. - Version your schema. The
version: 2field is not decoration. The day you rename a field, amigrate(doc)step on load is the difference between "old files still open" and a support fire.
# How Yugma handles this
Yugma's working format is a purpose-built serialized scene document over its editable semantic graph — per-object ids, parametric geometry params, material params, tags, and parent links, exactly the shape above. Save and reopen is byte-for-intent exact: a box is still a box, a group is still a group, and per-action undo history rides the same graph. GLB and USDZ export exist too, but they are outputs for interchange, not the save file. That separation is the honest lesson here: use glTF to hand your scene to another tool; use a semantic model you own to save your own work.
# Frequently asked questions
Does GLTFExporter lose data when I re-import into Three.js?
For an editable working file, yes — it bakes parametric geometry into vertex buffers and maps every material onto glTF's metallic-roughness model, so custom shaders, non-PBR params, and your node organization do not survive. It is still the correct choice for exporting to other tools, because rendering-everywhere is exactly what it optimizes for.
Can I keep using Object3D.toJSON() for anything?
It is fine for a quick clipboard copy of a single node inside one session, or as a debug dump. As a persistent save format it loses your app semantics and parametric params, and ObjectLoader cannot reconstruct intent it was never given. Serialize your own model for anything you plan to reopen.
How do I store textures in a serialized scene?
Store a URL or an asset id in the object, plus a manifest that maps ids to files. On load, resolve those through a loader (which caches by URL) and set them on the material. Never serialize the live THREE.Texture or a raw pixel blob — you will bloat the file and still fail to round-trip it.
The same serialized document is also what you send to a new collaborator on join — see adding real-time multiplayer to a 3D web app for how the save format doubles as the sync snapshot.