DEV Community

Richard Fu
Richard Fu

Posted on • Originally published at richardfu.net on

Unity WebGL Background Tabs: Autoplay & Performance Fix

Unity WebGL games face severe performance degradation in inactive browser tabs due to aggressive requestAnimationFrame throttling (~1 FPS). This creates race conditions and freezes when implementing autoplay features. We solved this by:

  1. Detecting tab visibility and skipping Unity communication entirely during background autoplay
  2. Tracking Unity’s internal state to detect and recover from desynchronization
  3. Adding strategic delays to accommodate Unity’s “wake-up” period when tabs become active
  4. Skipping blocking UI modals that wait for user interaction

Understanding Unity WebGL background tabs behavior is essential for any casino game, idle game, or simulation that uses autoplay features. Native web technologies (Three.js, PixiJS) don’t face these issues due to their direct JavaScript integration, making this a Unity-specific challenge.

The Problem: When Autoplay Meets Inactive Tabs

Background: Building a Game with Unity WebGL

We were building a browser-based game using Unity WebGL for its 3D rendering capabilities. The game architecture follows a hybrid model:

  • Unity Layer : Handles visual simulation, animations, and particle effects
  • Web Layer : Manages server communication, authentication, game logic, and UI

Communication between layers happens via Unity’s SendMessage API (Web → Unity) and Application.ExternalCall (Unity → Web).

The Autoplay Feature

Users requested an autoplay feature – click once, play multiple rounds automatically. Simple enough:


class GameplayManager {
  async playRound() {
    // 1. Request round result from server
    const result = await api.requestRound();

    // 2. Send result to Unity for visual simulation
    unityInstance.SendMessage('GameController', 'StartSimulation', JSON.stringify(result));

    // 3. Wait for Unity to complete animation
    await waitForUnityCallback('SimulationComplete');

    // 4. If autoplay enabled, play next round
    if (this.autoplayMode) {
      this.playRound(); // Recursive
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

It worked perfectly… until users switched browser tabs.


The Mystery: Everything Breaks in Background Tabs

Symptom 1: Autoplay Stops Completely

When a user enabled autoplay and switched tabs:

  • ✅ First round completes normally
  • ❌ Second round starts, but never finishes
  • ❌ Game frozen when user returns to tab

Console logs revealed:


[Game] Sending simulation to Unity...
[Unity] Received simulation data
[Unity] Starting animation...
[Game] Waiting for Unity callback...
[... nothing ...]

Enter fullscreen mode Exit fullscreen mode

Unity never called back. The animation never completed. Autoplay stuck forever.

Symptom 2: Seed Desynchronization

When users returned to the tab during autoplay:

  • ❌ Unity showed the wrong level/seed
  • ❌ Visual simulation didn’t match server results
  • ❌ Player saw corrupted/glitched gameplay

Example:


Background: Played seed #1705 (skipped Unity)
Background: Started seed #839 (skipped Unity)
User returns: Unity still on seed #1705
Web sends: "Start simulation for seed #839"
Unity: Generates seed #839 on top of #1705's level
Result: Corrupted level geometry, wrong obstacles

Enter fullscreen mode Exit fullscreen mode

Symptom 3: Success Rounds Never Complete

For rounds with positive results (score > 0):

  • ✅ Server response received
  • ✅ Score updated correctly
  • ❌ Game state stuck at “showing results” instead of “idle”
  • ❌ Next round never triggers

The logs showed:


[Game] Round completed with score: 150
[ResultsUI] Showing results modal...
[Game] Waiting for user to close modal...
[... stuck forever ...]

Enter fullscreen mode Exit fullscreen mode

The results modal waited for a user click that would never come in a background tab.


Understanding the Root Cause: Browser Tab Throttling

How Browsers Throttle Inactive Tabs

Modern browsers aggressively throttle inactive tabs to save CPU and battery:

API Active Tab Inactive Tab
requestAnimationFrame ~60 FPS ~1 FPS
setTimeout (< 4ms) As specified ≥1000ms
setInterval As specified ≥1000ms
Network requests Normal ✅ Normal
WebSocket Normal ✅ Normal

Key insight: Network APIs continue working normally, but anything timing-related gets severely throttled.

Why Unity WebGL Suffers More Than Native Code

Unity WebGL’s architecture makes it particularly vulnerable:

1. Separate Runtime Sandbox


Browser JS ←→ Message Queue ←→ Unity WebAssembly Runtime
   (60 FPS) (throttled!) (~1 FPS in background)

Enter fullscreen mode Exit fullscreen mode

2. Message Queue Delays


// Web sends message to Unity
unityInstance.SendMessage('GameObject', 'Method', data);
// ↓
// Message enters Unity's internal queue
// ↓
// Unity processes queue on next Update() call (every ~1000ms in background)
// ↓
// Unity executes method
// ↓
// Unity sends callback via Application.ExternalCall()
// ↓
// Callback enters browser's task queue
// ↓
// Browser processes callback on next frame (~1000ms later)

Enter fullscreen mode Exit fullscreen mode

Total latency in background: ~2-3 seconds per message

3. Animation Completion Detection

Unity animations rely on frame updates:


void Update() {
    animationTimer += Time.deltaTime; // Only updates at ~1 FPS
    if (animationTimer >= duration) {
        SendCompletionCallback();
    }
}

Enter fullscreen mode Exit fullscreen mode

At 1 FPS, a 3-second animation takes 3 minutes to complete.


Failed Solutions: What Didn’t Work

Attempt 1: Shorter Timeouts

Hypothesis: Maybe 100ms isn’t enough. Try longer delays.


setTimeout(() => {
  if (unityResponded) {
    continueAutoplay();
  }
}, 5000); // Wait 5 seconds instead of 100ms

Enter fullscreen mode Exit fullscreen mode

Result: ❌ Still frozen. Unity simply doesn’t respond at reasonable speeds in background tabs.

Attempt 2: Promise.race with Timeout

Hypothesis: Use timeout as fallback if Unity takes too long.


await Promise.race([
  waitForUnityCallback('complete'),
  new Promise(resolve => setTimeout(resolve, 10000))
]);

Enter fullscreen mode Exit fullscreen mode

Result: ❌ Timeout fires, but game state is now inconsistent. Unity still processing old simulation when we start new one.

Attempt 3: Visibility Change Detection

Hypothesis: Pause autoplay when tab becomes inactive.


document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    pauseAutoplay();
  }
});

Enter fullscreen mode Exit fullscreen mode

Result: ❌ Defeats the purpose. Users want autoplay to continue in background tabs.


The Solution: Skip Unity Entirely in Background

The breakthrough realization: Unity is only needed for visual feedback. Server responses contain all game logic.

Architecture Shift

Before:


Server → Web → Unity → Web → Next Round
         ↓ ↓ ↓
      Request Render Callback

Enter fullscreen mode Exit fullscreen mode

After:


Active Tab: Server → Web → Unity → Web → Next Round
Background: Server → Web → [Skip Unity] → Next Round

Enter fullscreen mode Exit fullscreen mode

Implementation: Four-Layer Solution

Layer 1: Tab Visibility Detection

Skip Unity communication when tab is hidden AND autoplay is active:


protected async onRoundStart(): Promise<void> {
  const isTabHidden = document.hidden;
  const isAutoplay = getUIManager()?.autoplayMode || false;

  if (!isTabHidden || !isAutoplay) {
    // Normal flow: send to Unity for visual animation
    await unityInterface.startRound();
    this.lastUnitySeed = null; // Reset seed tracker
  } else {
    // Background autoplay: skip Unity entirely
    console.log('[Background] Skipping Unity round start');
  }
}

Enter fullscreen mode Exit fullscreen mode

Apply the same pattern to all Unity communication points:

  • startRound() – Game initialization
  • startSimulation() – Simulation trigger
  • showResults() – Success animation
  • completeRound() – Cleanup

Layer 2: Seed State Tracking

Track which seed Unity currently has loaded to detect desynchronization:


export abstract class SeedGameplayManager extends BaseGameplayManager {
  private lastUnitySeed: string | number | null = null;

  protected async onSimulationStart(roundResponse: RoundResponse): Promise<void> {
    const currentSeed = roundResponse.events[0].seed;

    if (isTabHidden && isAutoplay) {
      // Background: don't update lastUnitySeed
      // This flags a mismatch when user returns
      skipUnityAndContinue();
    } else {
      // Check for mismatch from previous background play
      if (this.lastUnitySeed !== null && this.lastUnitySeed !== currentSeed) {
        console.log(`Seed mismatch: Unity=${this.lastUnitySeed}, need=${currentSeed}`);
        await unityInterface.startRound(); // Reset Unity
        await sleep(500); // Wait for Unity to wake up
      }

      unityInterface.startSimulation(stateJson);
      this.lastUnitySeed = currentSeed; // Update tracker
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this works:

  • Background spins don’t update lastUnitySeed
  • When user returns, we detect mismatch immediately
  • We reset Unity before sending new seed
  • This prevents level corruption

Layer 3: Smart Recovery with Wake-Up Delay

When tabs become active, Unity needs time to recover from low FPS:


if (this.lastUnitySeed !== null && this.lastUnitySeed !== currentSeed) {
  // Step 1: Reset Unity
  await unityInterface.startRound();

  // Step 2: CRITICAL - Wait for Unity to wake up
  // Tab just became visible, Unity transitioning from 1 FPS → 60 FPS
  console.log('[Recovery] Waiting 500ms for Unity wake-up...');
  await new Promise(resolve => setTimeout(resolve, 500));

  // Step 3: Now Unity is ready for new commands
  unityInterface.startSimulation(stateJson);
}

Enter fullscreen mode Exit fullscreen mode

Timeline:


0ms: Tab becomes visible
0ms: Send startRound() to Unity (reset command)
0-100ms: Unity at ~1-10 FPS, processing reset slowly
100-300ms: Unity FPS ramping up (10→30→60 FPS)
300-500ms: Unity stabilizes at 60 FPS, reset complete
500ms: Send startSimulation() - Unity processes immediately ✅

Enter fullscreen mode Exit fullscreen mode

Without the delay:


0ms: Send startRound() - Unity queues it
0ms: Send startSimulation() - Unity queues it
100ms: Unity processes startRound() slowly
200ms: Unity tries to process startSimulation() but internal state not ready
Result: Command lost or processed incorrectly ❌

Enter fullscreen mode Exit fullscreen mode

Layer 4: Skip Blocking UI

Modal dialogs that wait for user interaction block the entire async flow:


public async showResultsModal(): Promise<void> {
  if (this.latestScore === 0) return;

  // CRITICAL: Skip modal in background tabs
  // Modal waits for user click which never comes
  if (document.hidden) {
    console.log('[ResultsUI] Background - skipping modal');
    return; // Resolve immediately
  }

  // Normal flow: show modal and wait for user interaction
  return new Promise((resolve) => {
    const modal = createModal({
      content: resultsContent,
      onClose: () => resolve() // Waits for click
    });
  });
}

Enter fullscreen mode Exit fullscreen mode

The async blocking chain:


// gameplayManager.ts
protected async transitionToComplete(): Promise<void> {
  if (this.lastScore > 0) {
    await Promise.all([
      this.sendEndRoundRequest(),
      this.emit('roundComplete') // ← Waits for listeners
    ]);
  }
  this.completeRound();
}

// ResultsUI.ts
eventEmitter.on('roundComplete', async () => {
  await this.showResultsModal(); // ← Blocks if waiting for click
});

Enter fullscreen mode Exit fullscreen mode

Without the skip:


Background: Results modal created
Background: Promise waiting for click
Background: No user present to click
Background: Promise never resolves
Background: transitionToComplete() never completes
Background: completeRound() never called
Background: Game stuck in "showing results" state
Background: Autoplay frozen ❌

Enter fullscreen mode Exit fullscreen mode

With the skip:


Background: Detect document.hidden
Background: Skip modal, return immediately
Background: transitionToComplete() completes
Background: completeRound() → state = "idle"
Background: Autoplay continues ✅

Enter fullscreen mode Exit fullscreen mode

Results: Smooth Background Autoplay

Performance Metrics

Before fixes:

  • Background autoplay: ❌ Frozen after 1 round
  • Tab switch recovery: ❌ Corrupted visuals
  • Success animation completion: ❌ Stuck forever

After fixes:

  • Background autoplay: ✅ Continuous at server speed (~300ms/round)
  • Tab switch recovery: ✅ Automatic reset + sync (~500ms)
  • Success animation completion: ✅ Instant in background

Flow Comparison

Scenario: User enables autoplay, switches tabs for 30 seconds

Before:


0s: Round 1 starts, completes (Unity active)
3s: Round 2 starts
3.1s: User switches tab
3.1s: Unity receives simulation command (queued)
5s: Unity still processing at 1 FPS
10s: Unity still processing at 1 FPS
30s: User returns - game frozen on Round 2

Enter fullscreen mode Exit fullscreen mode

After:


0s: Round 1 starts, completes (Unity active)
3s: Round 2 starts
3.1s: User switches tab
3.1s: Detect hidden tab → skip Unity
3.4s: Round 2 completes (server-side only)
3.7s: Round 3 starts → skip Unity
4.0s: Round 3 completes
... 25 more rounds in 30 seconds ...
30s: User returns - seed mismatch detected
30s: Reset Unity + 500ms delay
30.5s: Round 28 plays with correct visuals ✅

Enter fullscreen mode Exit fullscreen mode

Why Three.js and PixiJS Don’t Have This Problem

The issues we faced are Unity WebGL-specific. Native web rendering libraries handle background tabs gracefully.

Architecture Comparison

Aspect Unity WebGL Three.js / PixiJS
Runtime Separate WebAssembly sandbox Native JavaScript
Communication Message queue (async, throttled) Direct function calls (instant)
Main Loop Unity’s Update() via RAF Your render loop via RAF
State Management Isolated in Unity C# code Shared JavaScript context
Background Impact Complete freeze (~1 FPS) Rendering paused, logic continues

Three.js Example

With Three.js, background autoplay is trivial:


class ThreeJSGame {
  async playRound() {
    // 1. Server request - works normally in background
    const result = await api.requestRound();

    // 2. Update game state - pure JavaScript, instant
    this.updateGameLogic(result);

    // 3. Conditionally render visuals
    if (!document.hidden) {
      await this.animateScene(result); // Skip in background
    }

    // 4. Complete round - instant
    this.onRoundComplete();

    // 5. Continue autoplay - no blocking!
    if (this.autoplayMode) {
      this.playRound();
    }
  }

  private async animateScene(result: GameResult) {
    // Three.js rendering - skipped when document.hidden
    return new Promise(resolve => {
      const animate = () => {
        if (this.animationComplete) {
          resolve();
          return;
        }

        // Update camera, objects, etc.
        this.camera.position.lerp(targetPos, 0.1);
        this.mesh.rotation.y += 0.01;

        // Render frame
        this.renderer.render(this.scene, this.camera);

        requestAnimationFrame(animate);
      };
      animate();
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

No need for:

  • ❌ Message queue synchronization
  • ❌ Seed state tracking
  • ❌ Wake-up delays
  • ❌ Manual desync detection

Just:

  • ✅ Skip rendering loop if document.hidden
  • ✅ Continue game logic at full speed
  • ✅ Everything stays synchronized automatically

PixiJS Example

PixiJS follows the same pattern:


class PixiGame {
  private animationLoop(): void {
    // Game logic - always runs
    this.updatePhysics(deltaTime);
    this.checkCollisions();
    this.updateScore();

    // Rendering - conditional
    if (!document.hidden) {
      this.renderer.render(this.stage);
    }

    requestAnimationFrame(() => this.animationLoop());
  }

  async playRound() {
    const result = await api.requestRound();

    // Update sprites, positions, etc. - instant
    this.updateGameObjects(result);

    // Wait for animation if visible
    if (!document.hidden) {
      await this.waitForAnimation(3000);
    }

    // Continue - no blocking
    if (this.autoplayMode) this.playRound();
  }
}

Enter fullscreen mode Exit fullscreen mode

Key Differences Explained

1. Synchronous State Access

Unity WebGL:


// Must send message and wait for callback
unityInstance.SendMessage('GameObject', 'GetScore', '');
// ... wait for Unity to process ...
// ... wait for callback ...
const score = await waitForCallback();

Enter fullscreen mode Exit fullscreen mode

Three.js/PixiJS:


// Direct access - instant
const score = this.gameState.score;

Enter fullscreen mode Exit fullscreen mode

2. No Communication Overhead

Unity WebGL:


Every operation requires:
1. Serialize data to JSON
2. Send via SendMessage
3. Unity deserialize JSON
4. Unity process in Update()
5. Unity serialize response
6. Send via ExternalCall
7. Browser deserialize response

Total: ~2-3 seconds in background tab

Enter fullscreen mode Exit fullscreen mode

Three.js/PixiJS:


Every operation is:
1. Direct function call

Total: <1ms

Enter fullscreen mode Exit fullscreen mode

3. Shared Context

Unity WebGL:


// Two separate worlds
const webState = { round: 5, score: 100 };
const unityState = { round: 3, score: 0 }; // Out of sync!
// Must manually synchronize

Enter fullscreen mode Exit fullscreen mode

Three.js/PixiJS:


// Single source of truth
class Game {
  state = { round: 5, score: 100 };

  updateLogic() { this.state.score += 10; }
  renderVisuals() { this.scoreText.text = this.state.score; }
  // Always in sync
}

Enter fullscreen mode Exit fullscreen mode

4. Selective Rendering

Unity WebGL:


// Unity's Update() always tries to run everything
void Update() {
    UpdatePhysics(); // Throttled to 1 FPS
    UpdateAnimation(); // Throttled to 1 FPS
    UpdateAI(); // Throttled to 1 FPS
    Render(); // Throttled to 1 FPS
    // Can't separate logic from rendering
}

Enter fullscreen mode Exit fullscreen mode

Three.js/PixiJS:


function gameLoop() {
  // Logic - always runs at full speed
  updatePhysics(deltaTime); // 60 FPS even in background
  updateAI(); // 60 FPS even in background

  // Rendering - skip in background
  if (!document.hidden) {
    renderer.render(scene); // 0 FPS in background
  }
}

Enter fullscreen mode Exit fullscreen mode

When to Use Each Technology

Choose Unity WebGL When:

✅ You need a full 3D engine with physics, lighting, particles

✅ You have existing Unity assets/expertise

✅ You’re targeting multiple platforms (desktop, mobile, console, web)

✅ Visual fidelity is critical

✅ Background tab performance is not a priority

Trade-offs:

  • Larger bundle size (~10-50 MB)
  • Compilation required
  • Background tab challenges (as discussed)
  • Less direct browser API access

Choose Three.js When:

✅ You need custom 3D rendering with full control

✅ Background tab performance matters

✅ You want smaller bundle sizes

✅ You need direct browser API integration

✅ You’re comfortable with JavaScript/TypeScript

Trade-offs:

  • More manual setup (physics, lighting, etc.)
  • Steeper learning curve for 3D graphics
  • No visual editor

Choose PixiJS When:

✅ You’re building 2D games

✅ Performance is critical

✅ Background tab support is required

✅ You want the smallest bundle size

✅ You need maximum browser compatibility

Trade-offs:

  • Limited to 2D (no 3D capabilities)
  • Manual sprite management
  • No built-in physics engine

Lessons Learned

1. Browser Tab Throttling is Aggressive

Don’t assume requestAnimationFrame, setTimeout, or any timing APIs work normally in background tabs. They don’t.

2. Network APIs Are Reliable

Fetch, WebSocket, and other network APIs continue working at full speed regardless of tab visibility. Build your architecture around this.

3. Unity WebGL Needs Special Handling

Unity WebGL’s sandboxed runtime creates unique challenges. Budget extra development time for cross-context communication and state synchronization.

4. Always Have a Fallback

When integrating external runtimes (Unity, iframes, Web Workers), always implement:

  • Timeout detection
  • State synchronization
  • Recovery mechanisms
  • Graceful degradation

5. Test in Real Conditions

Background tab behavior varies by:

  • Browser (Chrome, Firefox, Safari)
  • Device (desktop, mobile, tablet)
  • Battery state (plugged in vs. battery)
  • System load (other tabs, apps)

Always test with actual tab switching, not just document.hidden = true.


Conclusion

Unity WebGL is a powerful tool for bringing 3D games to the browser, but its sandboxed architecture creates unique challenges for features that must work in background tabs. By understanding browser throttling behavior and implementing strategic workarounds—skipping Unity during background operations, tracking synchronization state, adding recovery delays, and removing blocking UI—we achieved smooth autoplay functionality that works reliably regardless of tab visibility.

For new projects, consider whether Unity’s benefits (full 3D engine, cross-platform support, visual editor) outweigh its limitations (background tab challenges, large bundle size, communication overhead). Native web technologies like Three.js and PixiJS offer simpler architectures with better background tab support, at the cost of requiring more manual setup.

The key insight: Unity is for visual feedback. Keep your game logic in JavaScript, and treat Unity as a pure rendering layer.


Resources

The post Unity WebGL Background Tabs: Autoplay & Performance Fix appeared first on Richard Fu.

Top comments (0)