How do you architect a fast, cross-device Three.js app?
Three.js Developer
answer
A high-performance Three.js application treats frame time as a hard budget. Target 60 fps (16.6 ms) with CPU ≤6 ms and GPU ≤8 ms. Optimize geometry (meshopt/Draco), compress textures (KTX2/Basis), and cut draw calls via instancing and material atlases. Cull aggressively (frustum, occlusion, LOD), move physics and parsing to Web Workers, and stream assets by priority. Use dynamic resolution, GPU timers, and quality tiers to adapt per device without dropping frames.
Long Answer
A production Three.js app is organized around one rule: protect frame time. Smooth motion and responsive input beat raw fidelity, so every system has a budget and a fallback. This blueprint scales from mid-tier phones to desktops by profiling devices, pruning work, and adapting quality.
1) Capability profiles and budgets
Probe WebGL2/WebGPU, max texture size, extensions, and cores. Map to low/medium/high profiles with limits for triangles, draw calls, materials, texture MB, and shader cost. Expose the active profile so features can react at runtime.
2) Asset pipeline and authoring
Use glTF 2.0. Compress meshes with meshopt/Draco; merge static meshes by material. Prefer few shared PBR materials; atlas where possible. Encode textures to KTX2 (ETC1S/UASTC) with mipmaps; pack ORM channels. Prebake AO/lightmaps for static sets; keep dynamic lights sparse.
3) Visibility and LOD
Do hierarchical frustum culling; add room/portal culling indoors. Use multi-step LODs and impostors for distance. Pick LOD by screen-space error with hysteresis.
4) Shaders, materials, and batching
Favor standard materials; keep a tiny vetted custom-shader set. Share programs; vary via defines/uniforms. Instance repeated props; for skinned crowds use texture-based animation. Sort front-to-back, minimize alpha, and keep post-fx to one tonemap+bloom pass.
5) Off-thread work and main-thread hygiene
Move KTX2/glTF decoding, pathfinding, and physics into Workers with transferable buffers. Step physics at a fixed rate; interpolate on render. Drive timelines with one rAF clock, batch reads before writes, and avoid DOM/layout contention.
6) Streaming, memory, and lifecycle
Use a prioritized asset streamer (visible → predicted → decorative) with an LRU cache. On memory pressure or backgrounding, drop distant LODs and downshift texture resolution. Destroy WebGL resources on unload.
7) Rendering pipeline and pacing
Order the frame: input → simulate → cull/LOD → sort → draw → post. Target 60 fps (test at 90/120). Bind dynamic resolution to GPU timers and reduce shadows/particles before framerate. Prefer WebGPU when available; keep a tuned WebGL2 fallback.
8) Interaction, UI, and comfort
Raycast with layers; throttle pointer streams. Keep UI as DOM overlay or lightweight 2D; avoid heavy filters. Honor prefers-reduced-motion; add camera assists on fast moves.
9) Observability and auto-scaling
Track draw calls, triangles, texture MB, and p95 frame time. Auto-scale by telemetry: degrade shadows, SSAO, particles, then resolution. Persist user overrides and offer Safe mode.
10) Build, delivery, and guardrails
Code-split scenes/effects; prefetch on intent. Tree-shake, brotli, and lazy-load the KTX2 transcoder. Fail CI when budgets exceed caps; run perf shots and visual diffs. Record shader/material additions with ADRs to prevent sprawl.
With budgets, culling, streaming, and adaptive quality, complex Three.js scenes stay smooth across browsers and hardware.
Table
Common Mistakes
Animating or sorting without regard to overdraw, then blaming devices for jank. Shipping raw PNG/JPEG textures instead of KTX2, exploding memory and upload time. Too many unique materials and no atlas, so draw calls skyrocket. Running physics, decoding, and pathfinding on the main thread. Skipping frustum culling and LOD; rendering what the camera cannot see. Alpha everywhere: blended foliage and UI layers stack overdraw. Post-fx chains with multiple full-screen passes for small gains. Dynamic lights and deep shadow cascades. No dynamic resolution or quality tiers. Delivering one giant bundle; no code-split or lazy loaded transcoders. No counters or perf shots in CI, so regressions ship unnoticed. Ignoring color-space correctness. Relying on raycasts over entire scenes without spatial indices. Never honoring prefers-reduced-motion. Leaking WebGL resources on scene swaps; tabs slow down over time.
Sample Answers (Junior / Mid / Senior)
Junior:
“I target 60 fps and animate via requestAnimationFrame. I keep meshes light, use glTF with Draco, and convert textures to KTX2. I enable frustum culling, basic LODs, and instance repeated props. I avoid multiple post-fx passes and profile draw calls.”
Mid:
“I start with device profiling and quality tiers. The asset pipeline uses meshopt/Draco and KTX2; static meshes merge by material. I offload decoding and physics to Workers, stream assets by priority, and auto-scale via dynamic resolution bound to GPU timers. I keep one tonemap+bloom pass and throttle pointer raycasts.”
Senior:
“Architecture is budget-driven: limits for triangles, draw calls, materials, and texture memory per tier. Visibility is hierarchical culling + screen-space LOD; batches use instancing and shared programs. CI enforces asset budgets and perf shots; builds fail on regressions. Fallbacks keep WebGL2 fast, while WebGPU unlocks fewer submissions and compute-based effects.”
Evaluation Criteria
Strong answers anchor on frame-time budgets and show how to enforce them with device profiles, asset compression, culling, LODs, and batching. Look for glTF + meshopt/Draco, KTX2 textures, few shared materials, and atlases. Expect Workers for decoding/physics, prioritized streaming, and resource cleanup. Rendering should follow a stable pipeline with dynamic resolution tied to GPU timers and minimal post-fx. Candidates should mention observability (draw calls, triangles, texture MB, p95 frame time) and CI guardrails (asset budgets, perf shots, visual diffs). Red flags: main-thread everything, many unique materials, no LOD, alpha-heavy passes, deep shadow cascades, and giant bundles. Bonus: WebGPU path with WebGL2 fallback, impostors for distance, texture-based animation for crowds, and comfort features (reduced-motion, camera assists). Senior depth includes numeric targets (for example, triangles ≤ 2M, draw calls ≤ 1k, texture ≤ 400 MB high-tier) and a degradation ladder (shadows → particles → AO → resolution) with user overrides. They also test at 90/120 Hz and validate color space.
Preparation Tips
Build a reference scene with 1–2M triangles and repeated props. Export glTF, run meshopt/Draco, and convert textures to KTX2 with mipmaps. Add three device tiers and budgets; render a HUD with draw calls, triangles, texture MB, CPU/GPU time. Implement hierarchical frustum culling, two LOD steps, and instancing. Move KTX2/glTF decode and physics to Workers; use transferable buffers. Add a prioritized loader with LRU eviction and a toggle for dynamic resolution bound to GPU timers. Keep a single tonemap+bloom pass; compare to a chain of passes. Write perf tests that capture p95 frame time on desktop and mid-tier mobile. Fail CI when budgets exceed caps; record perf shots for a hero camera path. Test comfort features: reduced-motion, fast camera moves with assist, and DOM overlay UI. Verify linear/sRGB correctness. Profile shader cost with Spector.js and trim precision. Simulate memory pressure and confirm distant LODs drop first. Add a Safe mode and a manual quality slider; persist overrides. Test at 60 and 120 Hz; compare frame pacing and resolution scaling behavior.
Real-world Context
A configurator moved from raw PNGs to KTX2 and merged static meshes by material; download size fell 55% and time to first orbit halved. A city viewer added hierarchical culling and two LODs; p95 frame time on mid-tier Android dropped from 28 ms to 17 ms. A game-like demo replaced per-object updates with instancing and shared shader programs; draw calls fell 6× with identical visuals. A training tool offloaded physics and KTX2 decoding to Workers; input latency became stable under spikes. An e-commerce 3D gallery adopted dynamic resolution tied to GPU timers; instead of stutters, resolution stepped smoothly under load. A mapping app introduced impostors for far buildings and prebaked AO; overdraw shrank and battery life improved. Another project validated color space and removed redundant post-fx passes; GPU time per frame fell without visual loss.
Key Takeaways
- Treat frame time as the non-negotiable budget.
- Compress and atlas; minimize unique materials and draw calls.
- Cull ruthlessly; use LODs, impostors, and instancing.
- Offload decoding/physics to Workers; stream assets by priority.
- Adapt quality with GPU-timer-driven resolution and CI guardrails.
Practice Exercise
Scenario:
You must ship a cross-device Three.js scene (city block + vehicles + UI overlay) that stays at 60 fps on mid-tier mobile and scales to 120 Hz on desktop.
Tasks:
- Define three capability tiers (low/med/high) from runtime probes; set budgets for triangles, draw calls, materials, and texture MB. Render a tiny HUD.
- Build an asset pipeline: glTF + meshopt/Draco, KTX2 textures with mipmaps, ORM packing, merged static meshes by material; generate two LODs and impostors.
- Implement visibility: hierarchical frustum culling, optional portal culling, and LOD selection by screen-space error with hysteresis.
- Offload decoding and physics to Workers using transferable buffers; step physics at fixed rate and interpolate on render.
- Create a prioritized loader with LRU; stream visible → predicted → decorative. On memory pressure or backgrounding, drop distant LODs first and downshift texture resolution.
- Configure rendering: sort front-to-back, minimize alpha, single tonemap+bloom pass, dynamic resolution tied to GPU timers. Expose a Safe mode and manual quality slider.
- Add observability: counters (draws, triangles, texture MB), p95 frame time logs, and perf shots along a camera path. Fail CI on budget overruns.
- Validate comfort and UI: respects reduced-motion; DOM overlay UI avoids heavy filters; throttle pointer raycasts.
Deliverable:
A short runbook with budgets, quality ladder, loader priorities, and perf screenshots at 60 and 120 Hz, plus a postmortem template for any dropped-frame event.

