Quick answer: AsyncOperation.progress stops at 0.9 by design when allowSceneActivation is false. Unity reserves the final 10% for scene activation (calling Awake() and OnEnable()). Set allowSceneActivation = true when you’re ready, and map progress to a 0–100% range by dividing by 0.9f for your loading UI.
You set up async scene loading in Unity, built a nice loading screen with a progress bar, and everything seems to work — except the bar fills to 90% and then just sits there. Your coroutine is running, isDone never becomes true, and the scene never switches. This is one of the most common Unity gotchas, and the fix is simple once you understand the two-phase loading model.
Understanding Unity’s Two-Phase Async Loading
When you call SceneManager.LoadSceneAsync(), Unity splits the work into two distinct phases. The first phase covers progress values 0.0 through 0.9 and handles all the heavy lifting: reading the scene file, deserializing GameObjects, loading referenced assets, and setting up internal data structures. This is the part that actually takes time and benefits from running in the background.
The second phase covers progress values 0.9 through 1.0 and handles scene activation: calling Awake(), OnEnable(), and Start() on every object in the scene, enabling renderers, starting particle systems, and making the scene visible. This phase runs on the main thread and can cause a noticeable hitch if the scene has many objects with expensive initialization.
When allowSceneActivation is set to false, Unity completes phase one and then pauses. Progress stays at 0.9, and isDone remains false. This is intentional — it gives you a chance to finish any transition effects (fade-outs, animations, minimum display times for loading screens) before the scene switch happens.
The Correct Loading Coroutine Pattern
Here’s the pattern that handles both the progress bar and the activation timing correctly:
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class SceneLoader : MonoBehaviour
{
[SerializeField] private Slider progressBar;
[SerializeField] private float minimumLoadTime = 1.0f;
public void LoadScene(string sceneName)
{
StartCoroutine(LoadSceneCoroutine(sceneName));
}
private IEnumerator LoadSceneCoroutine(string sceneName)
{
float startTime = Time.unscaledTime;
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);
operation.allowSceneActivation = false;
// Phase 1: Loading (progress 0.0 to 0.9)
while (operation.progress < 0.9f)
{
float displayProgress = operation.progress / 0.9f;
progressBar.value = displayProgress;
yield return null;
}
// Loading is done, but enforce minimum display time
progressBar.value = 1.0f;
float elapsed = Time.unscaledTime - startTime;
if (elapsed < minimumLoadTime)
{
yield return new WaitForSecondsRealtime(
minimumLoadTime - elapsed
);
}
// Phase 2: Activate the scene
operation.allowSceneActivation = true;
// Wait for activation to complete
while (!operation.isDone)
{
yield return null;
}
}
}
Key details in this pattern: we divide operation.progress by 0.9f to normalize the value for the UI, so the progress bar fills completely to 100% when loading finishes. We use Time.unscaledTime and WaitForSecondsRealtime instead of their scaled equivalents so the loading screen works correctly even if Time.timeScale is set to 0. And we enforce a minimum display time so the loading screen doesn’t flash by on fast loads, which looks jarring.
Common Mistakes That Make It Worse
Several related mistakes often compound the stuck-at-0.9 issue:
Checking progress == 1.0f instead of isDone. Floating-point comparison with exact values is always risky. Use isDone to determine when loading is truly complete. Even after setting allowSceneActivation = true, it may take a frame or two for isDone to become true.
Forgetting that allowSceneActivation defaults to true. If you never set it to false, the scene activates as soon as loading finishes, and you’ll never see progress stuck at 0.9. The problem only appears when you explicitly disable auto-activation and then forget to re-enable it.
Destroying the coroutine’s MonoBehaviour. If the GameObject running your loading coroutine gets destroyed during the scene transition (because it’s part of the old scene), the coroutine stops and allowSceneActivation is never set to true. Either mark the loader with DontDestroyOnLoad or put it on a persistent manager object.
// Make sure the loader survives scene transitions
private void Awake()
{
DontDestroyOnLoad(gameObject);
}
Smoothing the Progress Bar
Raw AsyncOperation.progress values don’t increment smoothly — they jump in chunks as Unity finishes loading individual assets. For a polished loading screen, lerp toward the target value:
private IEnumerator LoadSceneCoroutine(string sceneName)
{
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);
operation.allowSceneActivation = false;
float displayProgress = 0f;
while (!operation.isDone)
{
float targetProgress = operation.progress < 0.9f
? operation.progress / 0.9f
: 1.0f;
displayProgress = Mathf.MoveTowards(
displayProgress, targetProgress,
Time.unscaledDeltaTime * 2f
);
progressBar.value = displayProgress;
if (operation.progress >= 0.9f
&& displayProgress >= 0.99f)
{
operation.allowSceneActivation = true;
}
yield return null;
}
}
This smooths out the visual progress and only activates the scene once the bar has visually reached 100%. The MoveTowards speed of 2f means the bar takes about half a second to catch up, which feels responsive without being jerky. Adjust the speed to match your loading screen’s aesthetic.