Calling SceneManager.LoadScene works, until your levels get big enough that the game freezes for three seconds every time it changes scene. The fix is to load scenes asynchronously, in the background, while a loading screen shows progress. This guide covers LoadSceneAsync from the basics to a reusable loading screen in Unity 6: progress bars that actually move, the infamous “stuck at 90%” behaviour, additive scenes, passing data between scenes, and the bugs that come with all of it.
LoadScene vs LoadSceneAsync
SceneManager.LoadScene("Level2") loads the next scene on the main thread at the end of the frame. Nothing else runs while it loads, so the screen freezes, audio can stutter, and on consoles or mobile a long freeze can even trip platform watchdogs.
SceneManager.LoadSceneAsync("Level2") streams the scene’s assets in the background over several frames while your game keeps rendering. You get an AsyncOperation back that reports progress and lets you decide when the new scene becomes active. Async loading isn’t magically faster overall (it can be slightly slower, because it shares frames with rendering), but the game stays responsive and you can show a proper loading screen.
Add Scenes to the Build
A scene can only be loaded by name or index if it’s in the build list. In Unity 6 that’s File > Build Profiles > Scene List (older versions: File > Build Settings). Drag your scenes in. The order sets the build index: index 0 loads first when the game starts.
Loading by name is clearer than by index, but names are strings and break silently when you rename a scene. A small static class of constants, or a serialized field holding the scene name, helps. Addressables can also load scenes by reference; see our Addressables guide if your project uses them.
Basic Async Loading
The classic version uses a coroutine:
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
public class LevelLoader : MonoBehaviour
{
public void Load(string sceneName) => StartCoroutine(LoadRoutine(sceneName));
IEnumerator LoadRoutine(string sceneName)
{
AsyncOperation op = SceneManager.LoadSceneAsync(sceneName);
while (!op.isDone)
{
Debug.Log($"Loading: {op.progress:P0}");
yield return null;
}
}
}
In Unity 6 you can also await the operation directly, which reads more naturally:
public async void Load(string sceneName)
{
await SceneManager.LoadSceneAsync(sceneName);
// runs after the new scene is loaded and active
}
If you’re deciding between the two styles, our Awaitable vs coroutines guide covers the trade-offs. One catch for both: the script running the load lives in the old scene. When that scene unloads, the coroutine stops and the object is destroyed. For anything after the switch, the loader must survive (see the loading screen below).
Progress and the 90% Rule
Two properties control how a load finishes:
op.progressgoes from 0 to 0.9 while loading assets, then jumps to 1 when the scene activates.op.allowSceneActivationdefaults to true. If you set it to false, the load runs until 0.9 and then waits. The scene only activates (runsAwake/Start, becomes visible) when you set it back to true.
That’s why so many loading bars get “stuck at 90%”: with activation held back, 0.9 is done. Normalise the value for display:
float display = Mathf.Clamp01(op.progress / 0.9f);
Holding activation is useful: you can wait for a minimum display time so the loading screen doesn’t flash for a split second, or wait for “Press any key”. Just remember that while allowSceneActivation is false, other async operations queue behind it, including Resources.LoadAsync and further scene loads. Don’t leave it false longer than needed.
Build a Reusable Loading Screen
The cleanest pattern is a small persistent loader with its own Canvas. It fades to black, shows a progress bar, loads the scene, then fades back in.
- Create a GameObject SceneLoader with a Canvas (Screen Space – Overlay, Sort Order 100 so it draws above everything).
- Add a full-screen black Image with a CanvasGroup for fading, plus a Slider (or a filled Image) and a “Loading…” label inside it.
- Add the script below, then save it as a prefab and place it in your first scene.
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class SceneLoader : MonoBehaviour
{
public static SceneLoader Instance { get; private set; }
[SerializeField] CanvasGroup fade;
[SerializeField] Slider bar;
[SerializeField] float fadeTime = 0.3f;
[SerializeField] float minShowTime = 0.75f;
bool busy;
void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
fade.alpha = 0f;
fade.blocksRaycasts = false;
}
public void Load(string sceneName)
{
if (!busy) StartCoroutine(LoadRoutine(sceneName));
}
IEnumerator LoadRoutine(string sceneName)
{
busy = true;
fade.blocksRaycasts = true; // stop clicks during the load
yield return Fade(1f);
bar.value = 0f;
float started = Time.unscaledTime;
AsyncOperation op = SceneManager.LoadSceneAsync(sceneName);
op.allowSceneActivation = false;
while (op.progress < 0.9f || Time.unscaledTime - started < minShowTime)
{
bar.value = Mathf.MoveTowards(bar.value, op.progress / 0.9f, Time.unscaledDeltaTime * 2f);
yield return null;
}
bar.value = 1f;
op.allowSceneActivation = true;
while (!op.isDone) yield return null;
yield return Fade(0f);
fade.blocksRaycasts = false;
busy = false;
}
IEnumerator Fade(float target)
{
float start = fade.alpha;
for (float t = 0f; t < fadeTime; t += Time.unscaledDeltaTime)
{
fade.alpha = Mathf.Lerp(start, target, t / fadeTime);
yield return null;
}
fade.alpha = target;
}
}
Now any script can call SceneLoader.Instance.Load("Level2"). Details worth copying:
- Unscaled time everywhere, so the loader still works if the game is paused with
Time.timeScale = 0(common when loading from a pause menu). - MoveTowards smooths the bar, since Unity’s progress jumps in big steps.
- A busy flag stops double-clicks starting two loads.
- The singleton check destroys duplicates if the first scene is loaded again. Our upcoming game manager guide goes deeper on safe singletons.
Additive Scenes
LoadSceneMode.Additive loads a scene on top of the current one instead of replacing it. This is how many projects are structured:
- A bootstrap scene that stays loaded forever, holding managers, audio and the loading screen.
- Level scenes loaded and unloaded additively as the player progresses.
- Separate scenes for UI, lighting, or chunks of an open world.
IEnumerator SwapLevel(string oldLevel, string newLevel)
{
yield return SceneManager.LoadSceneAsync(newLevel, LoadSceneMode.Additive);
SceneManager.SetActiveScene(SceneManager.GetSceneByName(newLevel));
yield return SceneManager.UnloadSceneAsync(oldLevel);
yield return Resources.UnloadUnusedAssets();
}
Call SetActiveScene after loading: the active scene is where Instantiate puts new objects and whose lighting settings (skybox, ambient light, fog) apply. Forgetting it is why additively loaded levels can look dark or spawn enemies into the wrong scene. Make sure only one scene has an EventSystem, one main camera and one Audio Listener, or you’ll get duplicate warnings and odd input.
Passing Data Between Scenes
Objects in a scene are destroyed when it unloads, so data needs somewhere to live across the switch. Options from simplest to most structured:
| Approach | Good for | Watch out for |
|---|---|---|
| Static class or field | Selected level, character choice | Static values survive play-mode exits if domain reload is disabled |
| DontDestroyOnLoad object | Managers, audio, player stats | Duplicates when the first scene reloads |
| ScriptableObject asset | Shared runtime state, settings | Changes made in the Editor persist into the asset |
| Persistent bootstrap scene | Larger games with many systems | Slightly more setup |
| Save file | Anything that must survive quitting | Needs serialization |
The ScriptableObjects guide covers the shared-state pattern, and our JSON save system handles data that must survive a restart.
💻 Long loads in the Editor too?
Slow scene loads and domain reloads often come down to the machine. A fast SSD and enough RAM make a huge difference. These laptops handle the Unity Editor well at every budget.
Making Loads Faster
A loading screen hides the wait. These shrink it:
- Compress textures and lower max sizes for things that never fill the screen. Texture data is usually the bulk of a scene.
- Avoid heavy work in Awake/Start in the new scene. Activation runs every
Awakeon the main thread, which is often the visible hitch after the bar reaches 100%. Spread setup over frames. - Don’t reference everything from one manager. A prefab array of every enemy forces all of them to load with the scene. Load on demand with Addressables.
- Bake lighting rather than computing it at runtime, and keep scenes reasonably sized; split huge worlds into additive chunks.
- Profile the load. The Profiler shows exactly which Awake or asset is slow.
Troubleshooting
“Scene ‘Level2’ couldn’t be loaded because it has not been added to the build settings”
Add the scene to File > Build Profiles > Scene List, and check the exact name (it’s case-sensitive and must not include the .unity extension or folder path unless the names are ambiguous).
The progress bar is stuck at 90%
You set allowSceneActivation = false and never set it back to true, or you’re waiting for progress == 1, which never happens while activation is held. Treat 0.9 as “ready” and divide by 0.9 for display.
The loading screen freezes at the end
That’s activation: every Awake and OnEnable in the new scene runs in one frame. Move expensive setup out of Awake, or spread it across frames with a coroutine.
The coroutine stops after the scene changes
The object running it was in the old scene and got destroyed. Call DontDestroyOnLoad on the loader, or run it from a persistent bootstrap scene.
The new scene is dark after loading
Additive loading without SetActiveScene, so lighting settings come from the old scene. Or the scene’s lighting was never baked; generate lighting in the Lighting window. When loading in the Editor, scenes that never had lighting generated also look darker than in builds.
Duplicate managers or “There are 2 audio listeners in the scene”
A DontDestroyOnLoad object exists in a scene that gets loaded again, or two additive scenes each have a camera and listener. Add the singleton duplicate check, and keep cameras and EventSystems in exactly one scene.
FAQ
How do I load a scene in Unity without freezing?
Use SceneManager.LoadSceneAsync from a coroutine or async method, and show a loading screen while it runs. Keep the loader alive with DontDestroyOnLoad.
Why does AsyncOperation.progress stop at 0.9?
Progress reaches 0.9 when loading is finished but the scene isn’t activated. It only jumps to 1 when activation completes. With allowSceneActivation false, it waits at 0.9.
What is additive scene loading?
Loading a scene alongside the scenes already open instead of replacing them. It’s used for persistent manager scenes, UI scenes and streaming world chunks.
Can I await LoadSceneAsync in Unity 6?
Yes. In Unity 6 an AsyncOperation can be awaited directly inside an async method, as an alternative to yielding it in a coroutine.
How do I show a loading screen for a minimum time?
Set allowSceneActivation to false, wait until progress reaches 0.9 and a minimum time has passed, then set it to true. Use unscaled time so it works while paused.
Does LoadSceneAsync load on another thread?
Asset loading and decompression happen in the background, but activation, Awake and Start always run on the main thread, which is why the last step can still hitch.
Reader questions
Asked by readers of this article and answered by AI. Behavior can differ between Unity versions, so test in yours.




Leave a Reply