← ALL NOTES
METHOD·2026.08·7 MIN READ
3D Model Loading Optimization
3d-visualization3d-design
The Problem

Opening the scale-transition demo left the page stuck on a "Loading model…" state for a long time, which hurt the demo experience. It was especially visible when sharing the link with a coworker — they would open it and wait a long time before seeing anything, which made a poor first impression.

Problem: slow loading
  └─ Direct cause: the model file is large (46 MB)
       └─ Every visit re-downloads it from the server
            ├─ Long network transfer time
            └─ No caching strategy at all

What does 46 MB mean in practice? On a typical 4G connection (about 20 Mbps), the download takes roughly 18 seconds; even on decent Wi-Fi (100 Mbps) it takes about 3-4 seconds. That excludes browser parsing and render time.

Background

GLB (GL Binary) is the binary packaging format for glTF — a standard, general-purpose 3D format, similar to JPEG/PNG for images. A GLB file typically bundles geometry data (vertex positions, normals, UVs — the largest part), materials/textures, and the scene graph.

Draco is Google's open-source 3D geometry compression algorithm, built specifically for the geometry portion of GLB/glTF files. It works like MP3 for audio: precision the eye can't perceive is discarded in exchange for a large size reduction. In this case, compression took the file from 46 MB to 11 MB (a 75% reduction), at the cost of an extra ~1 MB WASM decoder that has to load in the browser, and a CPU-side decompression step that can be slightly slower on low-end devices.

Service Worker (SW) is a browser background-proxy mechanism. It runs on its own thread and can intercept every network request a page makes, then decide whether to serve a local cache, pass the request through to the server, or do other custom handling.

HTTP cacheService Worker cache
Controlled byServer (via HTTP response headers)Frontend code (JS)
When it cachesAt request timeCan pre-cache proactively (install step)
Offline supportNoYes
Typical useRoutine static asset cachingPWAs, fine-grained cache control

WebAssembly (WASM) is a near-native-speed binary format that runs in the browser. The Draco decoder ships as WASM — fast to run, but it adds an extra ~1 MB file to download on first load.

Options Considered
OptionFirst loadRepeat loadEffortModel fidelity
① Swap in a smaller placeholder modelFastFastLowPoor
② Draco-compress the GLBFasterFasterMediumGood
③ Service Worker cacheUnchangedVery fastMediumGood
④ ② + ③ combinedFasterVery fastMediumGood

I chose option ④. Option ① changes the actual content of the demo, which wasn't acceptable. Option ② alone still means an 11 MB download on the second visit — still a noticeable wait. Option ③ alone does nothing for the first visit. Option ④ is complementary: each solution covers the case the other one doesn't.

Engineering takeaway: this kind of "combined punch" strategy shows up often in real projects — a single technique usually only solves half the problem, and finding two complementary approaches is what actually covers it end to end.

Implementation

Overall architecture.

User's browser
│
├─ First visit
│   ├─ Downloads sw.js, registers the Service Worker
│   ├─ SW install step: proactively fetches + caches after_moved_draco.glb
│   └─ Page loads the 11 MB GLB (Draco-compressed)
│       └─ DRACOLoader decodes via WASM → Three.js renders
│
└─ Second visit
    ├─ SW intercepts the GLB request
    ├─ Hits Cache Storage → returns directly, no network request
    └─ Near-instant open

Draco compression, using @gltf-transform/cli (a CLI toolkit for GLB/glTF, similar to ImageMagick for images):

BASH
npx @gltf-transform/cli draco \
  assets/models/after_moved.glb \
  assets/models/after_moved_draco.glb

On the Three.js side, GLTFLoader can't read a Draco-compressed file by default — it needs a DRACOLoader attached:

JAVASCRIPT
// before
new GLTFLoader().load('after_moved.glb', callback)

// after
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('CDN-path/draco/')
const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)
gltfLoader.load('after_moved_draco.glb', callback)

Service Worker lifecycle, in three stages:

install
  └─ proactively fetch and cache the GLB file
  └─ skipWaiting(): skip waiting, activate immediately

activate
  └─ clean up old cache versions (compare against 'scanat-v1')
  └─ clients.claim(): take control of the current page immediately

fetch (intercept requests)
  └─ if it's a .glb file → check cache → hit returns cache, otherwise goes to network

One detail that matters: c.put(request, res.clone()).catch(() => {}). If writing to the cache fails (full disk, private browsing, etc.), that error must not block the response back to the user — it has to be swallowed.

Version management. The SW defines const CACHE = 'scanat-v1'. When the model file updates, bumping this to 'scanat-v2' makes the SW automatically clear the old cache and re-cache the new file on activation — a standard cache-busting strategy.

Testing

Verified through the Network panel: the page loads after_moved_draco.glb (11 MB, not the original 46 MB); the DRACOLoader files (draco_wasm_wrapper.js, draco_decoder.wasm) load correctly; the SW status shows activated; and on a second refresh, the GLB request is served by the SW with no actual network request.

Issue found during verification: net::ERR_CACHE_WRITE_FAILURE. This error comes from the browser's HTTP disk cache layer, not the SW Cache API — the local dev server wasn't sending proper cache headers, so the browser's HTTP cache refused to write. It doesn't affect the SW Cache API, and production (Vercel) sets the correct headers automatically, so it could be ignored.

Lesson: local dev servers and production don't always behave the same way. When something "looks broken but actually works," it's worth figuring out exactly which layer the error is coming from before assuming it's a real bug.

Results
MetricBeforeAfter (first load)After (repeat load)
File size46 MB11 MB0 MB (cached)
Wi-Fi download time (est.)~4s~1s~0s
4G download time (est.)~18s~4s~0s
A Designer's Perspective

This is fundamentally a perceived performance problem. What a user experiences as "slow" isn't always the same as what's actually slow — a good loading animation can make something feel fast, and a genuinely fast operation can feel sluggish if it gives no feedback at all.

The framework used for choosing an approach — list every option without self-censoring, evaluate each across multiple dimensions (UX, implementation cost, maintenance cost), and look for complementary combinations rather than a single silver bullet — is close to how a design review works. A PM or Design Engineer making a technical decision follows the same reasoning, just weighted more toward engineering feasibility and long-term maintenance.

Written as a PRD, this would read: background — the demo's first load is slow enough to hurt its credibility when shown to a coworker; success metric — first load under 2s on typical Wi-Fi, repeat load under 0.5s; constraints — can't swap the model (hurts realism), can't add a backend (static site only); approach — frontend compression + frontend caching, ships independently with no backend involvement. That's the PM habit: translate a technical problem into a user-facing goal, state the constraints, evaluate options, and set a measurable target.

For the Design Engineer side of this: you don't need to write everything yourself, but you do need to be able to read what code is doing and where the real decision points are. Tooling matters — knowing a CLI like gltf-transform exists is often more useful than knowing exactly how it works internally. And understanding browser mechanisms (caching, Service Workers, WASM) makes it possible to talk to engineers as a peer, and to design with a real sense of where the technical edges are.

CONTINUE READING
Translating Figma Component Properties into Code Props2026.09Engineering a Figma Design System into Production Assets2026.09