DEV Community

Cover image for Angular Signals + Three.js: How We Hit 60fps Without Going Zoneless
Emmanuel
Emmanuel

Posted on

Angular Signals + Three.js: How We Hit 60fps Without Going Zoneless

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() and requestFrameIfNeeded() APIs

  • Track 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();
Enter fullscreen mode Exit fullscreen mode

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:

  1. It's experimental — API may change, limited community support
  2. Third-party libraries — Many Angular libraries assume Zone.js exists
  3. Migration effort — Existing apps need significant refactoring
  4. 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:

  1. OnPush change detection — Only update components when inputs/signals change
  2. Signal-based state — Fine-grained reactivity without Zone.js triggers
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  });
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  ],
};
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Additional Optimizations (Optional)

  • Adaptive geometry: Dynamically reduce sphere segments on low-memory or mobile devices (48–128 segments based on navigator.deviceMemory and hardwareConcurrency), 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);
Enter fullscreen mode Exit fullscreen mode

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

  1. Forgetting to call requestRender() after scene changes
  2. Calling requestRender() in a loop instead of once after batch updates
  3. Not using OnPush on components that read Three.js state
  4. 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

  1. You don't need experimental zoneless — OnPush + Signals + render-on-demand achieves 90% of the benefit
  2. Render-on-demand is the key — The animation loop runs, but actual rendering is conditional
  3. Signals bridge Angular and Three.js — Use effect() to sync reactive state to the scene
  4. Event coalescing helpsprovideZoneChangeDetection({ eventCoalescing: true })
  5. Measure everything — Use renderer.info and 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.

GlobePlay - Interactive Geography, Bird Migration & Quiz

Explore 241 countries, trace bird migration paths, and test your geography knowledge on an interactive 3D globe. Built with Three.js and Angular.

favicon globeplay.world

Top comments (0)