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:
- Detecting tab visibility and skipping Unity communication entirely during background autoplay
- Tracking Unity’s internal state to detect and recover from desynchronization
- Adding strategic delays to accommodate Unity’s “wake-up” period when tabs become active
- 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
}
}
}
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 ...]
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
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 ...]
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)
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)
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();
}
}
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
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))
]);
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();
}
});
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
After:
Active Tab: Server → Web → Unity → Web → Next Round
Background: Server → Web → [Skip Unity] → Next Round
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');
}
}
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
}
}
}
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);
}
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 ✅
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 ❌
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
});
});
}
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
});
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 ❌
With the skip:
Background: Detect document.hidden
Background: Skip modal, return immediately
Background: transitionToComplete() completes
Background: completeRound() → state = "idle"
Background: Autoplay continues ✅
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
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 ✅
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();
});
}
}
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();
}
}
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();
Three.js/PixiJS:
// Direct access - instant
const score = this.gameState.score;
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
Three.js/PixiJS:
Every operation is:
1. Direct function call
Total: <1ms
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
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
}
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
}
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
}
}
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
- Browser Tab Throttling (MDN)
- Unity WebGL Communication
- Three.js Documentation
- PixiJS Documentation
- requestAnimationFrame Throttling
The post Unity WebGL Background Tabs: Autoplay & Performance Fix appeared first on Richard Fu.
Top comments (0)