Key answer: 3D scroll performance is the discipline of keeping a scroll-driven WebGL scene at a stable frame rate — ideally a locked 60fps, and never dipping into visible jank — while the visitor scrolls. The frame budget on a 60Hz display is about 16.7 milliseconds; miss it and motion tears. The five changes that recover the most frames, in order, are: cap the pixel ratio at 2, decouple the scroll event from a single requestAnimationFrame render loop, pause rendering when the canvas is offscreen, render on demand instead of every frame when the scene is idle, and compress geometry and textures so the GPU has less to move. Everything else is tuning. Get those five right and a scroll scene that stuttered on a mid-range phone holds a smooth line, because the bottleneck in scroll-driven 3D is almost never the idea — it is redundant work the render loop was never asked to skip.
Table of contents
- What 3D scroll performance means
- What you'll learn
- Why it matters
- Architecture: where the frames go
- Cap the pixel ratio
- Decouple scroll from the render loop
- Pause when offscreen
- Render on demand
- Compress geometry and textures
- Real product evidence
- Profiling: find the real bottleneck
- SEO impact
- Accessibility
- Production trade-offs
- When to invest in 3D scroll
- When NOT to
- Decision matrix
- How AETumi approaches it
- GitHub and technical proof
- FAQ
- Related AETumi resources
- Conclusion
What 3D scroll performance means
3D scroll performance is the measurable smoothness of a scroll-driven WebGL scene — how consistently it hits its frame budget while the page scrolls, on the devices your visitors actually use. On a standard 60Hz display you have roughly 16.7ms per frame to read scroll position, update the scene, and draw it; on a 120Hz phone that shrinks to about 8ms. A scene that fits inside that budget every frame feels weightless. A scene that blows past it every few frames produces the tearing, stepping, and lag that make an otherwise beautiful page feel broken.
The important insight is that scroll-driven 3D is uniquely exposed to performance mistakes. A static hero image costs nothing after load; an autoplay video is decoded by dedicated hardware. But a scroll scene runs your JavaScript and re-draws the GPU on a continuous basis, and it does so precisely when the user is generating a flood of scroll events. Poor 3D scroll performance is almost always the result of doing work the frame did not need — rendering when nothing changed, rendering more pixels than the screen can show, or rendering a scene nobody is looking at.
What you'll learn
- The frame budget you are actually working against and where the milliseconds go
- Why capping
devicePixelRatiois the single highest-leverage change - How to decouple scroll input from a single requestAnimationFrame render loop
- How to pause the loop when the canvas leaves the viewport
- When and how to switch to on-demand rendering so an idle scene costs nothing
- How Draco and KTX2 compression cut GPU upload and memory
- How to profile with
renderer.infoand the browser to find the true bottleneck
Why it matters
Performance is not a polish step on a scroll scene — it is the feature. A pinned product teardown that judders communicates the opposite of the premium quality it was meant to signal, and mobile visitors, who are the majority on most storefronts, feel it worst because their GPUs and thermal headroom are smallest. Frame drops also cost battery and heat, which shortens sessions in ways analytics rarely attribute correctly.
There is a deterministic upside, too. Because a scroll-driven scene's state is a pure function of scroll progress, its cost is predictable and profileable in a way that free-running animation is not — you can scrub the whole timeline and watch the frame graph. That makes 3D scroll performance a solvable engineering problem rather than a mystery, provided you attack it with a budget in mind rather than adding effects until the page chokes.
Architecture: where the frames go
Every frame of a scroll scene spends time in four places: input (reading scroll and computing progress), update (writing progress onto camera, meshes, materials), draw (the GPU render call), and overhead (garbage collection, layout, event handling you did not intend). Jank is almost never in the update math — trigonometry on a handful of objects is trivial. It is in the draw call rendering too many pixels, in the loop drawing when nothing changed, and in overhead from firing renders directly off scroll events.
The stable architecture separates these cleanly: the input layer only ever writes a single progress number into a small state object, and one requestAnimationFrame loop is the only place that reads state and draws. This is the same input/state/render model behind the build in 3D scroll animation websites; this article is what you do once that skeleton exists and you need it fast on real hardware.
Cap the pixel ratio
The most expensive number in a WebGL scene is the pixel count, and it scales with the square of devicePixelRatio. A modern phone reporting a DPR of 3 asks the GPU to draw nine times as many pixels as a DPR of 1 — for detail the human eye largely cannot resolve at that density in a moving scene. Capping it is the single highest-leverage change in 3D scroll performance:
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
Explanation: Math.min(devicePixelRatio, 2) renders at native density on standard screens but clamps ultra-high-DPI phones to 2x, roughly halving the fragment work on a 3x device. Expected behavior: a scene that dropped frames on a phone scrolls smoothly with no visible loss of sharpness in motion. Trade-off: on a static, zoomed-in hero shot a sharp eye may notice slightly softer edges at 2x versus 3x; if a single frozen frame must be pixel-perfect, render that one frame at full DPR and drop back to the cap for the animated range.
Decouple scroll from the render loop
The most damaging beginner mistake for 3D scroll performance is calling renderer.render() from inside the scroll event handler. Scroll events fire in unpredictable bursts unaligned to display refresh, so you get several redundant draws per frame, dropped frames, and torn motion. Treat scroll strictly as input and render on your own clock:
const state = { progress: 0 };
let current = 0;
// input only — never renders
addEventListener('scroll', () => {
const max = document.documentElement.scrollHeight - innerHeight;
state.progress = max > 0 ? scrollY / max : 0;
}, { passive: true });
function tick() {
requestAnimationFrame(tick);
current += (state.progress - current) * 0.08; // ease toward target
mesh.rotation.y = current * Math.PI * 2;
renderer.render(scene, camera);
}
requestAnimationFrame(tick);
Explanation: the listener writes only state.progress; the rAF loop is the single place that eases and draws, so you get exactly one render per display frame no matter how many scroll events arrive. The { passive: true } flag also tells the browser the listener will not call preventDefault, letting it keep scrolling on the compositor thread. Expected behavior: frame rate stays flat during fast flicks instead of spiking with event volume. Trade-off: the lerp adds a few frames of catch-up latency; that weight is usually desirable, but drop the factor toward 1.0 for anything that needs instant response.
Pause when offscreen
There is no reason to render a scene nobody can see. If your scroll canvas is one section of a longer page, stop the loop's work the moment it leaves the viewport and resume on re-entry with an IntersectionObserver:
let visible = true;
new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
}, { threshold: 0 }).observe(renderer.domElement);
function tick() {
requestAnimationFrame(tick);
if (!visible) return; // skip update + draw while offscreen
current += (state.progress - current) * 0.08;
renderer.render(scene, camera);
}
Explanation: the observer flips a boolean; the loop keeps scheduling itself so it can resume instantly, but skips all update and draw work while the canvas is out of view. Expected behavior: scrolling through the rest of a long page costs nothing for this scene, and the whole document scrolls more smoothly because the GPU is free. Trade-off: the scene will not be "warm" the instant it reappears — render one frame immediately on the re-entry event if you need it visually settled before the user reaches it.
Render on demand
Many scroll scenes are only moving while the user scrolls; when scrolling stops, the scene is static, yet a naive loop keeps drawing 60 identical frames a second. On-demand rendering draws only when something actually changed. In vanilla Three.js you gate the draw on whether the eased value is still moving; in React Three Fiber you use frameloop="demand" and invalidate():
import { Canvas, useThree, useFrame } from '@react-three/fiber';
function ScrollModel({ progress }) {
const invalidate = useThree((s) => s.invalidate);
useFrame((state) => {
// only runs on invalidate; ease and request another frame if still moving
const target = progress.current;
state.camera.position.z += (6 - target * 3 - state.camera.position.z) * 0.1;
if (Math.abs(state.camera.position.z - (6 - target * 3)) > 0.001) invalidate();
});
return null;
}
// <Canvas frameloop="demand"> ... call invalidate() from the scroll handler
Explanation: with frameloop="demand" R3F renders zero frames until invalidate() is called; the scroll handler calls it, and the frame re-requests itself while the lerp is still settling, then goes quiet. Expected behavior: GPU usage drops to near zero when the user pauses, extending battery and freeing the main thread. Trade-off: continuous ambient motion (a slowly rotating hero, animated shader) is incompatible with pure on-demand rendering — reserve it for scenes that are genuinely idle between scrolls.
Compress geometry and textures
The GPU can only be as fast as the data you hand it. Uncompressed models and 4K textures inflate download, decode, and VRAM, and a first frame that stalls on a 30MB GLB ruins the entrance regardless of how tight your loop is. Compress on the pipeline, not at runtime:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const draco = new DRACOLoader().setDecoderPath('/draco/');
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const loader = new GLTFLoader();
loader.setDRACOLoader(draco);
loader.setKTX2Loader(ktx2);
loader.load('/models/product.glb', (gltf) => scene.add(gltf.scene));
Explanation: Draco compresses mesh geometry and KTX2/Basis gives GPU-native compressed textures that stay compressed in VRAM rather than being decoded to raw RGBA. Expected behavior: a multi-megabyte model drops to a fraction of its size and the first render arrives sooner, so the scroll scene is interactive earlier. Trade-off: Draco decoding costs a little CPU on load and adds a worker dependency; for a single tiny mesh it is overkill, but for anything a scroll scene shows in detail it pays for itself in memory and time-to-first-frame.
Real product evidence
The demo below is a production scroll scene from the AETumi library, running with every optimization in this article already wired in — a capped pixel ratio, a decoupled single render loop, offscreen pausing, and compressed assets. Watch the frame rate hold steady as the camera moves with the scrollbar and reverses cleanly on scroll-up; that stability under motion is the visible signature of a scene built to a frame budget rather than one that renders whatever it is handed. It proves the practical point of the whole guide: the same scene can stutter or glide depending entirely on whether the loop is asked to skip the work it does not need, and this one is asked. It ships as editable source you own for life.
Profiling: find the real bottleneck
Guessing is slower than measuring. Three.js exposes cheap render statistics you can read every frame, and the browser's performance tools show you exactly where the 16.7ms went:
function tick() {
requestAnimationFrame(tick);
renderer.render(scene, camera);
// renderer.info.render.calls → draw calls this frame
// renderer.info.render.triangles → triangles drawn
// renderer.info.memory.geometries / .textures → live GPU resources
}
Explanation: renderer.info tells you whether you are draw-call bound (too many separate objects — merge geometry or instance), triangle bound (decimate models), or leaking (geometry/texture counts climbing across navigation means you forgot to dispose). Expected behavior: a healthy scroll scene holds a low, flat draw-call count and stable memory. Trade-off, stated honestly: if the numbers are already low and the page still janks, the cost is elsewhere — layout thrash from resizing the canvas on every scroll, or a heavy React re-render — and the browser's Performance panel, not renderer.info, is where you find it.
SEO impact
3D scroll performance is a ranking concern, not only a comfort one, because Core Web Vitals feed Google's page-experience signals. A heavy scene that blocks the main thread damages Interaction to Next Paint, and a large GLB that renders into the hero can wreck Largest Contentful Paint. The fix is the same discipline: keep all rankable text in real HTML outside the <canvas>, lazy-mount the scene after first paint so it never blocks LCP, and defer the model download until the section is near the viewport. Done that way the crawler reads a fast, text-first document and the visitor still gets the experience — the WebGL layer is an enhancement, never the content.
Accessibility
The cheapest frame is the one you never draw, and honoring prefers-reduced-motion both respects users who are sensitive to motion and eliminates the entire render loop for them:
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
renderer.render(scene, camera); // one static frame, then stop
} else {
requestAnimationFrame(tick);
}
Rendering a single static "hero frame" for reduced-motion users is a performance win as well as an accessibility one. Beyond that, keep real content and controls in the DOM so keyboards and screen readers work, never hijack the scroll, and ensure any control layered over the moving canvas keeps a visible focus state and sufficient contrast. A well-built scroll page is fully usable with the animation off.
Production trade-offs
Optimizing for 3D scroll performance costs engineering time and adds moving parts — a build pipeline for compression, an IntersectionObserver, on-demand invalidation logic, a reduced-motion branch, and disposal on teardown. Each is a small maintenance surface. The honest position is that this budget only pays back on pages where the 3D moment genuinely matters; on a text-first site the smartest performance decision is not to ship a scroll scene at all and to use a static image or a short muted video instead. Performance work is worth doing well when the effect earns its place, and worth skipping entirely when it does not.
When to invest in 3D scroll performance
| Invest when… | Why it pays back |
|---|---|
| A pinned hero or teardown carries the whole pitch | The moment fails if it stutters |
| A meaningful share of traffic is on mid-range phones | That is where the frame budget is tightest |
| The scene stays on screen through a long scroll range | Continuous rendering makes waste expensive |
| The brand is premium and expects flawless motion | Jank reads as low quality regardless of the art |
| You reuse the scene across many pages or templates | Optimization amortizes across every deployment |
When NOT to
| Skip the 3D (and its perf cost) when… | Use instead |
|---|---|
| The page is text-first (docs, blog, most B2B) | Fast, flat HTML and CSS |
| One clip conveys the whole idea | An autoplay-muted, lazy-loaded video |
| The audience is on low-end or metered devices | A static hero image with a poster |
| The team can't maintain a WebGL codebase | A CSS or Lottie micro-interaction |
| The 3rd dimension communicates nothing specific | Don't spend the frame budget at all |
Decision matrix
| Technique | Frames recovered | Effort | Use when |
|---|---|---|---|
| Cap devicePixelRatio at 2 | Very high | Trivial | Always, first change to make |
| Decouple scroll from render loop | High | Low | Any scroll-driven scene |
| Pause offscreen (IntersectionObserver) | High (on long pages) | Low | Scene is one section of many |
Render on demand (invalidate) | High (when idle) | Medium | No continuous ambient motion |
| Draco + KTX2 compression | Medium (load + memory) | Medium | Detailed models or large textures |
| Merge geometry / instancing | Medium (draw-call bound) | Medium–high | Many separate meshes |
How AETumi approaches it
Expert Note — cap the pixel ratio before you profile anything else. In practice the DPR clamp resolves the majority of "my scroll scene is janky on mobile" reports on its own, because uncapped 3x rendering triples the fragment work for detail nobody perceives in motion. Make it your first line, then measure. Chasing draw calls before capping DPR is optimizing the wrong end of the pipeline.
Expert Note — a leak looks like a slow decline, not a spike. If a single-page app's scroll scene gets slower the longer someone browses, watch renderer.info.memory.geometries and .textures across navigations. Numbers that climb and never fall mean you are not disposing geometries, materials, and the renderer on unmount. Fix disposal and the "mysterious" degradation disappears — it was never the animation.
AETumi is an AI-native 3D web platform, and every scroll template it ships is built to the frame budget described here: a capped pixel ratio, a decoupled single render loop, offscreen pausing, compressed assets, and a reduced-motion path, all wired in before you touch the content. Because the templates come with an AI build prompt for Claude Code, Cursor, or the AETumi MCP, you can restyle a scene without re-introducing the performance mistakes this guide exists to prevent. You buy once and own it for life — Standard $19, Pro $39, Premium $99, and Full Stack $129 (full source plus the AETumi MCP workflow) — so the render-loop engineering is solved and you spend your time on the idea, not on chasing dropped frames.
GitHub and technical proof
The threejs-scroll-animation repository in the AETumi GitHub organization is a runnable reference for the performance patterns above. It loads Three.js r160 as native ES modules over an import map (no UMD bundle), so you can read the source with no build step, and it demonstrates the capped pixel ratio, the single decoupled requestAnimationFrame loop, the IntersectionObserver offscreen pause, and the prefers-reduced-motion branch in one small scene. Its limitations are stated plainly: it is a single-scene demonstrator rather than a full site framework, its on-demand path is illustrative rather than production-hardened for every ambient-motion case, and it assumes a modern WebGL-capable browser. The performance notes call out the DPR cap and offscreen pause as the two changes that most affect mobile frame rate. Use it as a skeleton, profile your own geometry against it, and layer your choreography on top.
FAQ
What frame rate should a 3D scroll scene target? Aim for the display's native refresh — 60fps on most screens, which means fitting all input, update, and draw work into about 16.7ms per frame, and higher on 120Hz devices. More important than the peak number is consistency: a steady 60fps feels better than a scene that bounces between 90 and 40. Profile on a mid-range phone rather than a dev machine, because that is where the budget is tightest and where most visitors are.
Why is my scroll scene janky only on mobile? Almost always the pixel ratio. Phones report a devicePixelRatio of 2–3, and cost scales with its square, so an uncapped scene draws up to nine times the pixels of a desktop at DPR 1. Cap it with Math.min(devicePixelRatio, 2) and re-test before touching anything else — this single change resolves most mobile jank on its own.
Should I render every frame or only on demand? If the scene is static between scrolls, render on demand — draw only when scroll or the easing changes something, and let the GPU idle otherwise. This saves battery and frees the main thread. If the scene has continuous ambient motion, such as a slowly spinning hero, on-demand rendering does not apply and you run a normal capped loop with offscreen pausing instead.
Does compressing models really matter for scroll performance? Yes, mostly for time-to-first-frame and memory rather than steady-state frame rate. Draco shrinks geometry and KTX2/Basis textures stay compressed in VRAM instead of decoding to raw RGBA, so a heavy model becomes interactive sooner and leaves more headroom for the render loop. For a single trivial mesh the setup is overkill, but for any model a scroll scene shows in detail it is worth the pipeline step.
How do I find what is causing dropped frames? Read renderer.info each frame to see draw calls, triangles, and live geometry/texture counts — that tells you if you are draw-call bound, triangle bound, or leaking. If those numbers are already low and the page still janks, the cost is elsewhere: use the browser's Performance panel to catch layout thrash from resizing the canvas on scroll or heavy framework re-renders.
Related AETumi resources
- AETumi 3D scroll collection — production scroll templates built to this frame budget
- Three.js fundamentals — the rendering library underneath every scroll scene
- WebGL fundamentals — the layer Three.js wraps, for deeper optimization
- React Three Fiber — the React binding, including
frameloop="demand" - 3D scroll animation websites — how to build the scene this guide optimizes
Conclusion
Good 3D scroll performance is not luck — it is the removal of work the frame never needed. Cap the pixel ratio at 2, decouple scroll input from a single requestAnimationFrame render loop, pause when the canvas is offscreen, render on demand when the scene is idle, compress your geometry and textures, and profile with renderer.info before you guess. Hold a real budget on a mid-range phone, ship a reduced-motion frame, and keep rankable content in the DOM, and a scene that once stuttered will glide. Browse the AETumi 3D scroll collection for templates built to exactly this budget, study the working code in threejs-scroll-animation, and see how the scene is assembled in 3D scroll animation websites. AETumi is an AI-native 3D web platform — its scroll templates ship with full source and AI build prompts, buy once own for life. Start with a template at aetumi.app/pricing and ship a scroll scene that stays smooth.
More from the AETumi library
Real, production-ready assets — preview the motion, grab the source.

