---
title: "Performance and Optimization: Keeping a Three.js App Fast"
type: synthesis
tags: [threejs, performance, optimization, post-processing, responsive, render-on-demand]
created: 2026-08-02
updated: 2026-08-02
sources: ["raw/web_community-optimize-lots-of-objects.md", "raw/web_community-rendering-on-demand.md", "raw/web_community-aligning-html-elements-to-3d.md", "raw/web_community-responsive-design.md", "raw/web_community-post-processing.md"]
confidence: high
threejs_docs_snapshot: "2026-08-02"
---

## Comparison

Four techniques from four different raw sources all trade *something* for frame-rate or battery life. None of them is free, and they interact:

| Technique | What it saves | What it costs | Primary page |
|---|---|---|---|
| Merge geometry / instancing | Draw-call count (~19,000 → 1 in the globe example; 20fps → 60fps) | Per-object independence — merged objects can't move or be removed individually without re-splitting | [[concepts/performance]] |
| Render on demand | Continuous GPU work and battery on idle frames | Extra bookkeeping (reentrancy guards for damped controls, explicit renders after async loads) | [[concepts/performance]] |
| Responsive/HD-DPI sizing | GPU pixel-fill work (rendering at 1x instead of up to 9x on high-DPI mobile) | Visual crispness, if left at 1x | [[concepts/responsive-rendering]] |
| Post-processing passes | — (this one *costs*, never saves) | An extra full-screen render per pass, layered on top of everything else | [[concepts/post-processing]] |
| HTML overlay occlusion (raycasting) | Correctness (hides labels behind objects) | Per-label raycast cost, worse than the naive "always show" version | [[concepts/performance]] |

## Analysis

The four raw sources converge on one underlying idea: **the fastest thing to render is the thing you don't render, or render less often.** Concretely:

- **Fewer draw calls beats fewer triangles.** The globe example didn't get from 20fps to 60fps by simplifying box geometry — it got there by merging ~19,000 separate `Mesh` draw calls into one, using `BufferGeometryUtils.mergeGeometries`. Per-object color survived the merge via vertex-color attributes rather than per-mesh materials. See [[concepts/performance]] for the full before/after code and the general principle (trees, rocks, fences, voxel chunks).
- **Frame rate you don't need is pure waste.** [[concepts/performance]]'s render-on-demand section and [[concepts/responsive-rendering]]'s HD-DPI section are the same argument at two different layers: don't compute frames nobody will see the difference in (render-on-demand), and don't compute pixels at a resolution the GPU can't afford (manual `devicePixelRatio` scaling instead of blindly enabling `renderer.setPixelRatio`). Both explicitly call out mobile GPUs and battery life as the reason this matters more than it looks.
- **Every "correctness" feature has a shadow cost.** [[concepts/controls|OrbitControls]] damping requires a render-on-demand app to add a reentrancy guard (`requestRenderIfNotRequested`) or it infinite-loops. HTML label occlusion requires either a per-label raycast (slow) or, for the sphere-anchored special case, a cheap dot-product shortcut that only works because the geometry is a unit sphere. [[concepts/post-processing|Post-processing]] is the most literal version of this: it is pure added cost (an extra full-screen pass per effect) purchased for visual quality, and it must be resized in lockstep with the renderer and camera or it silently renders at the wrong resolution.
- **The techniques compose, and composing them is where bugs live.** The rendering-on-demand pattern is explicitly built *on top of* the responsive-design resize function (`resizeRendererToDisplaySize`) from a separate article, and the merged-geometry globe example is explicitly built *on top of* the render-on-demand pattern (rendering once after data/texture load rather than continuously). Post-processing needs the same resize discipline as the base renderer, just at the `EffectComposer` layer (`composer.setSize`) instead of the renderer layer. Each layer that's added — merging, on-demand rendering, HD-DPI scaling, post-processing, HTML overlays — needs its own resize/update hook wired into the same render loop, and skipping one hook while adding the next layer is the most common way these techniques silently stop working together.

## Recommendations

1. **Start with responsive sizing correctly, before anything else.** Every technique below assumes `resizeRendererToDisplaySize` (from [[concepts/responsive-rendering]]) is already wired into the render loop and gates the camera's `aspect`/`updateProjectionMatrix()` update. Decide deliberately whether to render at `devicePixelRatio` (crisper, up to 9x the pixel cost on high-DPI mobile) or at 1x (cheaper, browser upscales) — don't default to `renderer.setPixelRatio()` if the app will ever need its literal drawing-buffer size for post-processing, `gl_FragCoord` shaders, screenshots, or GPU picking.
2. **Default to rendering continuously only if the scene is actually animating every frame.** Otherwise, render once and wire re-renders to real triggers: [[concepts/controls|OrbitControls]]' `change` event, `window`'s `resize` event, async load completions, and GUI/input changes — all funneled through a single `requestRenderIfNotRequested`-style guard so damped controls can't create an infinite loop.
3. **When a scene has "lots of" anything (hundreds to tens of thousands of similar small objects), merge before optimizing anything else.** Bake per-instance transforms into geometry with `applyMatrix4` via a small reusable helper hierarchy, merge with `BufferGeometryUtils.mergeGeometries`, and recover per-instance color via vertex-color attributes rather than per-mesh materials. Only give up single-mesh merging for the subset of objects that genuinely need independent runtime movement or removal.
4. **Add post-processing last, and budget it deliberately.** Each `Pass` is an extra full-screen render — decide which effects earn their cost, keep `RenderPass`/`OutputPass` as the fixed bookends, and remember `composer.setSize()` must be called anywhere `renderer.setSize()`/camera aspect already are.
5. **For HTML-anchored labels, match the occlusion technique to the scene's shape, not the other way around.** Use the cheap dot-product visibility test when anchors sit on a shared sphere/known surface; fall back to raycasting-based occlusion (accepting its cost) only when objects are arbitrary and overlapping; use a data-driven filter (e.g. area/importance threshold) to cap total label count before worrying about per-label occlusion cost at all.

## Pages Compared

[[concepts/performance]], [[concepts/responsive-rendering]], [[concepts/post-processing]], [[concepts/controls]], [[concepts/raycasting-and-picking]], [[concepts/threejs-overview]]
