How we optimized WebGL rendering in Angular using OnPush, Signals, and render-on-demand—without the experimental zoneless API
This article assumes familiarity with Angular change detection and basic Three.js concepts.
Context: Production Angular app (~500 DAU) with an interactive WebGL globe used in long-running sessions (30–90 minutes).
Problem: Our Three.js globe ran at 30fps because Zone.js triggered change detection on every requestAnimationFrame tick 60 unnecessary CD cycles per second. At 30fps, the globe felt laggy and drained laptop batteries.
A 3D scene should not re-render 60 times per second if nothing changed.
Solution: Combined ChangeDetectionStrategy.OnPush with Angular Signals and a render-on-demand pattern that only draws frames when the scene actually changes.
Ownership: The GlobeSceneService is the single owner of the render lifecycle and performance budget.
Start/stop animation loop on init/destroy
Expose
requestRender()andrequestFrameIfNeeded()APIsTrack metrics and expose health signals
This makes rendering behavior predictable, debuggable, and enforceable across the application.
Result: Consistent 60fps, 90% GPU reduction when idle, and zero Zone.js interference with the render loop.
The Problem: Zone.js Meets requestAnimationFrame
Three.js applications need a continuous animation loop. The standard pattern looks like this:
// Standard Three.js animation loop
function animate() {
requestAnimationFrame(animate); // Schedule next frame
controls.update(); // Update camera controls
renderer.render(scene, camera); // Render the scene
}
animate();
This works great—until you put it inside an Angular application.
The issue: Zone.js schedules requestAnimationFrame callbacks inside the Angular zone, which may trigger change detection depending on component strategy. At 60fps, that's 60 change detection cycles per second, even if nothing in your Angular templates changed.
The symptoms:
- GPU usage at 20%+ even when idle (no user interaction)
- FPS drops to 30fps on mid-range devices
- Battery drain on laptops and mobile devices
- UI jank when Angular templates try to update during renders
Why Not Just Use Experimental Zoneless?
Angular 18+ offers provideExperimentalZonelessChangeDetection(). It sounds perfect—no Zone.js, no problem. But there are real tradeoffs:
- It's experimental — API may change, limited community support
- Third-party libraries — Many Angular libraries assume Zone.js exists
- Migration effort — Existing apps need significant refactoring
- Testing complexity — Test utilities behave differently
We chose a hybrid approach: keep Zone.js for the benefits it provides (automatic change detection for UI interactions), but prevent it from interfering with Three.js.
The Solution: Three Interlocking Patterns
Our solution combines three patterns:
- OnPush change detection — Only update components when inputs/signals change
- Signal-based state — Fine-grained reactivity without Zone.js triggers
- Render-on-demand — Only render Three.js frames when the scene changes
Let's look at each one.
Pattern 1: OnPush Everything
Every component uses ChangeDetectionStrategy.OnPush:
// src/app/pages/globe/globe.ts
import {
Component,
ChangeDetectionStrategy,
signal,
computed,
effect
} from '@angular/core';
@Component({
selector: 'app-globe',
standalone: true,
templateUrl: './globe.html',
changeDetection: ChangeDetectionStrategy.OnPush, // ← Critical
})
export class GlobePage {
// Component implementation
}
With OnPush, Angular only checks the component when:
- An
@Input()reference changes - A signal the template reads updates
- An event handler in the template fires
- You manually call
ChangeDetectorRef.markForCheck()
Crucially, requestAnimationFrame callbacks don't trigger OnPush components.
Pattern 2: Signals for Reactive State
Instead of RxJS BehaviorSubjects or plain properties, we use Angular Signals throughout:
// src/app/pages/globe/services/globe-scene.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class GlobeSceneService {
// Reactive state with signals
readonly isInitialized = signal(false);
readonly cameraInteractionState = signal<boolean>(false);
// Private mutable state for Three.js (not reactive)
private scene!: Scene;
private camera!: PerspectiveCamera;
private renderer!: WebGLRenderer;
private controls!: OrbitControls;
// Animation state
private needsRender = true;
private isAnimating = false;
}
Signals provide:
- Automatic template updates — Templates re-render when signals they read change
- Fine-grained reactivity — Only affected components update, not the whole tree
- No subscription management — Unlike RxJS, no need to unsubscribe
Pattern 3: Render-on-Demand
This is the core optimization: instead of rendering every frame, we only render when something changes.
Always stop the animation loop on component or service destroy using DestroyRef.onDestroy() (services) or ngOnDestroy() (components) to prevent duplicate render loops during navigation.
// src/app/pages/globe/services/globe-scene.service.ts
@Injectable({ providedIn: 'root' })
export class GlobeSceneService {
// Animation state
private animationId?: number;
private lastFrameTime = 0;
// Soft cap at ~60fps (used for throttling, not locking. 1000ms / 60)
private readonly frameInterval = 16.67;
private needsRender = true;
private isAnimating = false;
// Start the animation loop
startAnimation(): void {
if (this.isAnimating) return;
this.isAnimating = true;
this.animate();
}
// Stop the animation loop
stopAnimation(): void {
if (this.animationId !== undefined) {
cancelAnimationFrame(this.animationId);
this.animationId = undefined;
this.isAnimating = false;
}
}
// In GlobeSceneService (service owns render lifecycle)
private readonly destroyRef = inject(DestroyRef);
constructor() {
// Ensure we stop the loop when the service is destroyed
this.destroyRef.onDestroy(() => this.stopAnimation());
}
private animate(currentTime: number = 0): void {
this.animationId = requestAnimationFrame((time) => this.animate(time));
// Frame throttling: skip if called too soon
const deltaTime = currentTime - this.lastFrameTime;
if (deltaTime < this.frameInterval) {
return; // Skip this frame
}
const deltaTimeSeconds = deltaTime / 1000.0;
this.lastFrameTime = currentTime;
// Update OrbitControls (may set needsRender via 'change' event)
this.controls.update();
// Check for active animations (e.g., bird migrations)
const hasActiveMigrations =
this.globeMigrationService.isInitialized() &&
this.globeMigrationService.hasActiveAnimations();
// Animate migration paths if active
if (hasActiveMigrations) {
this.globeMigrationService.animate(deltaTimeSeconds);
this.needsRender = true;
}
// ONLY RENDER IF SOMETHING CHANGED
if (this.needsRender || hasActiveMigrations) {
this.renderer.render(this.scene, this.camera);
this.needsRender = false; // Reset flag after render
}
}
requestRender(): void {
this.needsRender = true;
}
}
The key insight: the animation loop runs continuously, but renderer.render() only executes when needsRender is true.
Why not use RxJS for the animation loop?
You can model requestAnimationFrame with RxJS (e.g., an observable driven by animationFrameScheduler), but for hot, imperative rendering loops the extra abstraction adds overhead and complexity. Three.js expects imperative control, and an imperative loop avoids the costs of Observable allocation, scheduling, and operator chains on each tick. We use RxJS for state and event streams and keep the render loop imperative for minimal overhead.
Triggering Renders: The Event-Driven Approach
needsRender is set explicitly in response to real scene changes:
1. Camera Movement (OrbitControls)
// src/app/pages/globe/services/globe-scene.service.ts
private setupControls(): void {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
// Configure controls
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.minDistance = 3;
this.controls.maxDistance = 15;
// Track interaction state for UI
this.controls.addEventListener('start', () => {
this.cameraInteractionState.set(true); // Signal update for UI
});
this.controls.addEventListener('end', () => {
this.cameraInteractionState.set(false);
});
// CRITICAL: Request render when camera moves
this.controls.addEventListener('change', () => {
this.needsRender = true; // ← Triggers actual render
});
}
2. User Interactions in Components
// src/app/pages/globe/globe.ts
@Component({...})
export class GlobePage {
private globeSceneService = inject(GlobeSceneService);
// When user selects a country
onCountryClick(country: string): void {
this.selectedCountry.set(country);
this.applyCountryHighlight(country);
this.globeSceneService.requestRender(); // ← Request render
}
// When quiz state changes
constructor() {
effect(() => {
const selectedCandidate = this.quizStateService.selectedCandidate();
const gameState = this.quizStateService.gameState();
if (!selectedCandidate || gameState === 'idle' || gameState === 'ended') {
this.quizIntegration.clearQuizCandidateHighlight();
this.globeSceneService.requestRender(); // ← Request render
}
});
}
}
Signals + Effects for Angular-Three.js Bridge
Angular Signals and effect() create a clean bridge between Angular's reactive system and Three.js's imperative API:
// src/app/pages/globe/globe.ts
@Component({...})
export class GlobePage {
// Angular signals for UI state
protected readonly selectedCountry = signal<string | null>(null);
protected readonly isLoading = signal(false);
// Computed values for templates
protected readonly migrationCardData = computed<readonly MigrationCardData[]>(() => {
const activePaths = this.migrationState.activePaths();
const migrations = this.migrationState.migrations();
const species = this.migrationState.species();
return activePaths.slice(0, 3).map((activePath) => {
const migration = migrations.find(m => m.id === activePath.migrationId);
const speciesData = species.find(s => s.id === migration?.speciesId);
return {
speciesName: speciesData?.commonName ?? 'Unknown',
scientificName: speciesData?.scientificName ?? '',
distance: migration?.totalDistance ?? 0,
// ... more computed fields
};
});
});
constructor() {
// Effect: Sync Angular state → Three.js scene
effect(() => {
const selectedIds = this.countrySelectionService.selectedCountries();
// Update Three.js texture based on Angular signal
if (this.countryIdTextureService.getSelectionMaskTexture()) {
this.countryIdTextureService.updateSelectionMask(selectedIds);
// Note: updateSelectionMask internally calls requestRender()
}
});
}
}
The effect() function watches signals and runs whenever they change. This is how Angular state flows into Three.js without Zone.js interference.
App Configuration: Event Coalescing
We keep Zone.js but optimize it with event coalescing:
// src/app/app.config.ts
import {
ApplicationConfig,
provideZoneChangeDetection,
} from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
// Keep Zone.js, but coalesce events for better performance
provideZoneChangeDetection({ eventCoalescing: true }),
// ... other providers
],
};
eventCoalescing: true batches multiple synchronous events into a single change detection cycle. This reduces CD overhead without removing Zone.js entirely.
Performance Results
Before Optimization
| Metric | Value | Notes |
|---|---|---|
| FPS | 30fps | Zone.js CD overhead |
| GPU Idle | 20%+ | Continuous rendering |
| CD Cycles | 60/sec | Every rAF triggers CD |
| Battery | Poor | Constant GPU/CPU usage |
After Optimization
| Metric | Value | Improvement |
|---|---|---|
| FPS | 60fps | 2x faster |
| GPU Idle | 2-5% | 90% reduction |
| CD Cycles | On-demand | ~99% reduction |
| Battery | Excellent | Only active when needed |
How to Measure
// Add to your scene service for debugging
private logPerformance(): void {
const info = this.renderer.info;
console.log({
drawCalls: info.render.calls,
triangles: info.render.triangles,
geometries: info.memory.geometries,
textures: info.memory.textures,
});
}
In production, we collect FPS, frame time, draw calls, and memory usage into a lightweight performance monitor backed by signals. This lets us surface real-time health indicators in the UI and catch regressions early:
readonly currentFPS = computed(() => this.metrics().fps);
readonly isPerformanceGood = computed(() => this.metrics().fps >= 55);
Additional Optimizations (Optional)
Adaptive geometry: Dynamically reduce sphere segments on low-memory or mobile devices (48–128 segments based on
navigator.deviceMemoryandhardwareConcurrency), cutting vertex count by up to 75%.InstancedMesh for particles: Batch hundreds of migration particles into a single draw call instead of individual meshes:
new THREE.InstancedMesh(geometry, material, MAX_PARTICLES);
Tradeoffs and Gotchas
When This Pattern Works Best
- 3D visualizations with intermittent user interaction
- Data dashboards where renders are event-driven
- Games with predictable update cycles
- Existing Angular apps where full zoneless migration is risky
When to Consider Full Zoneless
- New greenfield projects without third-party Zone.js dependencies
- Extreme performance requirements where even coalesced Zone.js is too much
- Simple apps without complex async operations
Common Mistakes
-
Forgetting to call
requestRender()after scene changes -
Calling
requestRender()in a loop instead of once after batch updates - Not using OnPush on components that read Three.js state
- Putting Three.js objects in signals — signals are for primitive/serializable state
The requestRender() Checklist
Call it after:
- Changing material properties
- Moving objects
- Updating textures
- Adding/removing scene objects
- Changing lights
Don't call it:
- Inside the animation loop (it's already handled)
- Multiple times in the same synchronous block (once is enough)
- For Angular-only state changes (signals handle that)
Key Takeaways
- You don't need experimental zoneless — OnPush + Signals + render-on-demand achieves 90% of the benefit
- Render-on-demand is the key — The animation loop runs, but actual rendering is conditional
-
Signals bridge Angular and Three.js — Use
effect()to sync reactive state to the scene -
Event coalescing helps —
provideZoneChangeDetection({ eventCoalescing: true }) -
Measure everything — Use
renderer.infoand a performance monitor to validate improvements
The 3D Global Dashboard now runs at consistent 60fps with 2-5% GPU usage when idle. Users report dramatically better battery life, and the UI remains responsive even during complex animations without sacrificing Angular's developer ergonomics or maintainability.
Top comments (0)