Back to Journal

Coroutines and Async in Unity: Waiting Without Stopping the World

A Unity game lives inside a single loop that must finish every frame on time, so waiting has to be done by pausing and resuming work across frames, never by blocking the thread that draws the world.

Every game is, at bottom, a loop that refuses to end. It reads the input, advances the simulation, draws the picture, and then does all of it again, sixty or more times every second, for as long as the player is willing to sit in front of it. Unity hides this loop behind friendly callbacks such as Update and FixedUpdate, but the loop is always there, and it has one unforgiving demand: each pass must finish before the next frame is due. A script that stops to wait for something, even for a moment, does not merely pause itself. It holds the whole world still.

And yet games are full of waiting. A door should swing open over half a second, a torch should gutter and relight after a pause, an enemy should hesitate before it lunges, a message should fade from the screen once the player has had time to read it. None of these can be written as a plain sequence of instructions with a sleep in the middle, because sleeping on the thread that runs the game would freeze every other object along with it, and the frame counter would stall like a clock whose pendulum someone has caught in mid swing.

Unity offers two families of tools for this problem. The older one, the coroutine, is built from a curious reuse of a C# language feature and has served developers for well over a decade. The newer one draws on async and await, the general .NET machinery for asynchronous work, and was given a native Unity form with the Awaitable type in Unity 2023.1. Both rest on the same quiet principle, which is that waiting should mean stepping aside and asking to be woken later, not standing in the doorway.

The single loop

To understand why waiting is so delicate, one has to see how little room there is inside a frame. At sixty frames per second the engine has roughly 16.7 milliseconds for everything: physics steps, animation, every MonoBehaviour's Update, culling, and the preparation of draw calls for the graphics card. Almost all of your gameplay code runs on a single thread, usually called the main thread, and Unity's scene objects, transforms, components and most of its API may only be touched from that thread. There is no second worker quietly carrying on while one script dawdles.

Suppose a well meaning programmer writes a loop inside Update that spins until three seconds have passed, checking Time.time on every iteration. The loop will indeed end after three seconds, but during those three seconds no frame will be rendered, no input will be read and no other script will run. The window may even be marked unresponsive by the operating system. The same happens with Thread.Sleep, which is simply a more polite way of committing the identical sin: the main thread is occupied, and an occupied main thread is a dead game.

The traditional workaround, before reaching for any special feature, is to turn the wait into state. A script keeps a timer field, subtracts Time.deltaTime from it each frame, and acts only when it reaches zero. This is honest and efficient, and for a single countdown it is often the best choice. The trouble begins when the behaviour has several stages, each with its own delay and its own conditions, because then the script becomes a small state machine of flags and counters, and the readable sequence the designer had in mind dissolves into a scattering of if statements.

Iterators as coroutines

The coroutine is Unity's answer to that dissolving sequence, and it is built on C# iterators. A method that returns IEnumerator and contains the keyword yield is compiled into a hidden class that remembers where it stopped. Each time something calls MoveNext on it, the method runs until the next yield statement, hands back a value, and freezes, its local variables preserved like insects in amber. Iterators were designed for producing sequences lazily, but Unity noticed that a method which can freeze and resume at chosen points is exactly what a timed behaviour needs.

When you pass such an iterator to StartCoroutine, Unity takes ownership of it and calls MoveNext at the right moments within the player loop. The value you yield tells the engine when to resume you. Writing yield return null asks to be continued on the next frame, after the Update calls have run. Writing yield return new WaitForSeconds(2f) asks to be continued once two seconds of scaled game time have passed. Other instructions exist for other moments: WaitForFixedUpdate resumes after the physics step, and WaitForEndOfFrame resumes once rendering of the frame is complete.

The result is that the door, the torch and the hesitating enemy can each be written top to bottom, as a story. Rotate the door a little, yield return null, repeat until it is open; wait a second; play the creak. The code reads in the order things happen, and the state machine still exists, but the compiler has written it for you. One can even yield another coroutine, which suspends the outer one until the inner one has finished, so that complicated sequences are assembled out of small, separately testable pieces.

There are costs worth knowing. Each call to StartCoroutine allocates a small amount of managed memory for the iterator object, and each new WaitForSeconds is another allocation, which is why careful code often caches a WaitForSeconds instance in a field when the same delay is used repeatedly. WaitForSeconds also obeys Time.timeScale, so setting the time scale to zero for a pause menu will stop it entirely; when a delay must ignore the pause, WaitForSecondsRealtime counts unscaled time instead.

Starting, stopping and dying

StartCoroutine returns a Coroutine object, and keeping that reference is the cleanest way to stop the routine later with StopCoroutine. Unity also accepts a string name for both calls, but the string form only works for coroutines started by name and is brittle when methods are renamed. StopAllCoroutines halts every coroutine running on that particular MonoBehaviour, which is a blunt instrument but sometimes exactly what a reset requires. Stopping is immediate: the iterator is simply never resumed, so any cleanup it meant to perform after its next yield never happens.

The lifetime of a coroutine is bound to the object that started it, and the rules are more particular than many people expect. If the GameObject is deactivated with SetActive(false), all of its coroutines stop, and reactivating the object does not bring them back; they must be started again, often from OnEnable. If the GameObject or the component is destroyed, the coroutines die with it. Disabling only the MonoBehaviour by setting enabled to false, however, does not stop its coroutines at all, a detail that has surprised many a programmer debugging a supposedly sleeping script.

This binding is both a comfort and a trap. It is a comfort because a coroutine cannot easily outlive its owner and reach into a destroyed object. It is a trap because a coroutine started on one object to animate another will die if its host is switched off, even though the animated object remains perfectly alive. A common pattern is to run long lived routines from a manager object that never deactivates, and to start short, local effects from the object they affect, so that the lifetime of the effect and the lifetime of its owner agree.

Async methods in Unity

C# has its own general model for waiting, the async method, in which await suspends execution until a task completes and the rest of the method runs later as a continuation. Unity supports this model, and when an async method is called from the main thread, Unity's synchronization context arranges for the continuation to run back on the main thread, during a later pass of the player loop. That is what makes it possible to await a delay and then safely move a transform, much as one would after a yield in a coroutine.

Async methods can do things coroutines cannot. They can return values through a typed task, which makes a sequence like loading a save file and then reading its contents far more natural than the callback juggling a coroutine would require. They propagate exceptions to whoever awaits them, so errors surface where they can be handled rather than being logged and forgotten. They also compose with the wider .NET ecosystem of libraries that already expose asynchronous methods for networking, file input and output, and web requests.

The traditional tool for a delay here was Task.Delay, and the traditional difficulty was that the Task type knows nothing of Unity. It allocates generously, it counts real time with no regard for Time.timeScale, and it does not stop when the GameObject that launched it is destroyed. In the editor, an awaited delay can even complete after you have left Play Mode, and its continuation will then try to touch objects that no longer exist, producing errors that seem to come from nowhere.

Awaitable and the main thread

Unity 2023.1 introduced the Awaitable class to give async code a native footing in the engine. It offers methods such as Awaitable.NextFrameAsync, WaitForSecondsAsync, FixedUpdateAsync and EndOfFrameAsync, which mirror the familiar yield instructions but can be awaited. Awaitable instances are pooled internally to reduce allocations, and they are resumed by the player loop itself. The trade is that an Awaitable is meant to be awaited once; holding onto one and awaiting it a second time is not supported, because the pooled object may already be serving someone else.

Awaitable also makes moving between threads explicit. Awaiting Awaitable.BackgroundThreadAsync continues the method on a worker thread from the thread pool, where heavy computation such as parsing a large file or generating terrain data can run without stealing time from the frame. Awaiting Awaitable.MainThreadAsync brings execution back to the main thread before it touches anything in the scene. The discipline is strict: code on the background side must not read or write transforms, components, or most other engine objects, because the Unity API is not thread safe and will often refuse loudly if called from the wrong place.

Cancellation deserves the same care as threading. Because an async method does not die with its GameObject, a method that awaits for a long time should accept a CancellationToken and pass it into each await. MonoBehaviour provides destroyCancellationToken, which is cancelled when the component is destroyed, so a script can tie its asynchronous work to its own lifetime with one argument. Without such a token, a villager who has been removed from the world can still, a moment later, try to walk to the well, and the error that follows is a ghost of a script that should have gone quiet.

Choosing between them

Coroutines remain a sound choice for gameplay sequences that live and die with a single object: an animation of a gate, a flicker of firelight, a wave of enemies released at intervals. Their lifetime rules, inconvenient though they sometimes are, do much of the bookkeeping for you, and every Unity developer can read them. Their weaknesses are equally clear. They cannot return values cleanly, exceptions inside them do not travel to the caller, and they offer no way to step off the main thread for heavy work.

Async methods with Awaitable suit work that is closer to a process than to a performance: loading and saving, talking to a server, computing something expensive in the background and then presenting the result. They demand more discipline, chiefly about cancellation and about which thread a given line runs on, but they reward it with code that reads plainly and handles failure honestly. In Crown & Ashes, for instance, a torch flickering through the night is the natural province of a coroutine, while preparing a freshly generated piece of the world is better shaped as asynchronous work.

Whichever tool is chosen, the underlying rule never changes, and it is worth holding onto when the details blur. The frame must be finished on time, and the main thread must never be held hostage by a script that wants to wait. Coroutines and async methods are two ways of keeping that promise, by turning a pause into a request to be resumed later. Used with an understanding of when they wake, what kills them and where they run, they let a game breathe slowly while its loop keeps beating at full speed.