Introduction
When writing asynchronous code in Unity, Coroutine and UniTask have long been the practical choices.
A major reason is that many Unity APIs must be called from the main thread.
If you simply use .NET Task and move work to a background thread, you can run into problems the moment you touch objects such as GameObject, Transform, or SceneManager.
UniTask has been a very convenient solution to this constraint.
await UniTask.SwitchToThreadPool();
// Heavy computation, JSON parsing, binary parsing, etc.
var result = HeavyWork();
await UniTask.SwitchToMainThread();
// Touch Unity APIs
gameObject.name = result.Name;
However, starting with Unity 2023.1, Unity introduced the standard Awaitable API.
In Unity 6, APIs such as Awaitable.NextFrameAsync(), Awaitable.WaitForSecondsAsync(), Awaitable.BackgroundThreadAsync(), and Awaitable.MainThreadAsync() are available.
In other words, Unity now provides a standard way to write a certain amount of async/await code.
So, in the Unity 6 era, do we still need UniTask?
This article is not a basic UniTask tutorial. Instead, it focuses on what changed with the arrival of Awaitable, and where UniTask still remains valuable.
Conclusion
Here is the conclusion first.
With the arrival of Awaitable, Unity is no longer in a world where "UniTask is the only realistic way to use async/await."
However, if you want to operate asynchronous workflows properly in an actual game application, UniTask is still very useful.
A rough breakdown looks like this:
| Use case | Recommended choice |
|---|---|
| Waiting until the next frame | Awaitable is often enough |
| Waiting for a few seconds | Awaitable is often enough |
| Lightweight coroutine replacement | Awaitable is often enough |
| Library code where you want to avoid external dependencies | Returning Awaitable can be a good fit |
| Waiting for multiple async operations together | UniTask is stronger |
| Large-scale loading, networking, Addressables control | UniTask is stronger |
| Practical cancellation handling | UniTask is stronger |
| Fine-grained PlayerLoopTiming control | UniTask is stronger |
| Tracking leaked or forgotten async operations | UniTask is stronger |
Very roughly:
Small async code: Awaitable
Application-wide async infrastructure: UniTask
That is the mental model I use.
What changed with Awaitable?
The biggest change is that Unity now supports code like this as a standard feature:
await Awaitable.BackgroundThreadAsync();
// Now on a ThreadPool thread
var result = HeavyWork();
await Awaitable.MainThreadAsync();
// Back on the Unity main thread
gameObject.name = result.Name;
This is a big deal.
Previously, even if you wanted to use async/await in Unity, using .NET Task directly often did not fit well with Unity's main-thread restrictions.
You can move work to a background thread with Task.Run.
var result = await Task.Run(() =>
{
return HeavyWork();
});
However, touching Unity APIs inside that code is dangerous.
var result = await Task.Run(() =>
{
// NG
return transform.position;
});
Many Unity APIs are designed to be used from the main thread.
Because of that, you need a design that explicitly returns to the main thread before touching Unity APIs.
UniTask made this very natural.
await UniTask.SwitchToThreadPool();
var result = HeavyWork();
await UniTask.SwitchToMainThread();
ApplyResultToUnityObject(result);
Today, Unity's standard Awaitable API can express the same basic idea.
await Awaitable.BackgroundThreadAsync();
var result = HeavyWork();
await Awaitable.MainThreadAsync();
ApplyResultToUnityObject(result);
This means that introducing UniTask only to switch back to the main thread is less necessary than it used to be.
Task and Awaitable also differ in continuation timing
The arrival of Awaitable did not just add more APIs that can be used with async/await.
.NET Task and Unity's Awaitable also differ in how continuations are handled.
In Unity, a continuation from a Task called on the main thread can be posted to UnitySynchronizationContext, and may resume on the next Update tick.
On the other hand, Unity's Awaitable is designed to run continuations synchronously when the operation completes.
That means that when dealing with Unity-provided asynchronous operations, Awaitable is designed to avoid unnecessary one-frame delays more easily.
This does not mean "Awaitable is always faster."
But it does mean that Awaitable is designed around Unity's frame loop, while Task is a general-purpose .NET abstraction.
That distinction is worth keeping in mind.
await is not magic that automatically moves work to another thread
This is an easy misunderstanding.
Writing await does not automatically move the entire operation to a background thread.
await Awaitable.BackgroundThreadAsync();
This means that the following continuation resumes on a background thread.
Conversely:
await Awaitable.MainThreadAsync();
This means that the following continuation resumes on the Unity main thread.
So the following code is dangerous:
await Awaitable.BackgroundThreadAsync();
// NG: touching Unity APIs from a background thread
gameObject.name = "Loaded";
The correct pattern is to return to the main thread before touching Unity APIs.
await Awaitable.BackgroundThreadAsync();
var result = HeavyWork();
await Awaitable.MainThreadAsync();
gameObject.name = result.Name;
The same idea applies to UniTask.
await UniTask.SwitchToThreadPool();
var result = HeavyWork();
await UniTask.SwitchToMainThread();
gameObject.name = result.Name;
Awaitable does not remove Unity's main-thread restriction.
What changed is that Unity now provides standard APIs to work with that restriction.
Awaitable is very convenient as a coroutine replacement
Awaitable can replace many common coroutine patterns quite naturally.
Waiting until the next frame:
await Awaitable.NextFrameAsync();
Waiting for a fixed amount of time:
await Awaitable.WaitForSecondsAsync(1.0f);
Waiting for the FixedUpdate timing:
await Awaitable.FixedUpdateAsync();
Waiting until the end of the frame:
await Awaitable.EndOfFrameAsync();
For lightweight use cases, this is often enough.
For example, UI effects, simple waits, short samples, and small libraries that want to avoid external dependencies can often use Awaitable without bringing in UniTask.
private async Awaitable ShowMessageAsync(string message)
{
label.text = message;
await Awaitable.WaitForSecondsAsync(1.0f);
label.text = "";
}
For this level of code, UniTask is not mandatory.
What Awaitable replaced, and what it did not
Awaitable did not erase all of UniTask's value.
However, some of UniTask's previous value became less unique.
For example:
Writing next-frame waits with async/await
Writing WaitForSeconds-like waits with async/await
Writing EndOfFrame-like waits with async/await
Returning to the main thread using Unity-standard APIs
Moving to a background thread using Unity-standard APIs
These can now be written fairly naturally with Awaitable.
On the other hand, UniTask still has strong value in areas such as:
WhenAll / WhenAny / WhenEach
Fine-grained PlayerLoopTiming control
Frame-based APIs such as DelayFrame
Unity-oriented CancellationToken workflows
UniTaskTracker
Integration with Addressables, DOTween, uGUI, and other surrounding libraries
So the value that became less unique is:
Making async/await usable in Unity at all
The value that remains is:
Making asynchronous workflows easier to operate across a full application
Once you separate these two ideas, the decision becomes much clearer.
So where is UniTask still strong?
If Awaitable can switch threads, does that mean UniTask lost its value?
Personally, I see it less as "UniTask lost value" and more as "UniTask's role shifted."
Previously, UniTask often served as:
The practical foundation for using async/await in Unity
After Awaitable, its role is closer to:
A practical foundation for operating larger asynchronous workflows in Unity
The difference becomes clear in the following areas.
UniTask strength 1: WhenAll / WhenAny / WhenEach are practical
In real loading code, a single async operation is rarely the whole story.
You often want to start multiple operations, such as loading master data, Addressables assets, textures, or network responses, and then wait for all of them.
var playerTask = LoadPlayerAsync();
var itemTask = LoadItemAsync();
var questTask = LoadQuestAsync();
await UniTask.WhenAll(playerTask, itemTask, questTask);
Receiving multiple results is also clean.
var (player, item, quest) = await UniTask.WhenAll(
LoadPlayerAsync(),
LoadItemAsync(),
LoadQuestAsync()
);
This is very useful in practice.
If you await each operation one by one, they become sequential.
var player = await LoadPlayerAsync();
var item = await LoadItemAsync();
var quest = await LoadQuestAsync();
In this form, LoadItemAsync does not start until LoadPlayerAsync has finished.
If you start all operations first and then wait for them together, operations that can run concurrently will proceed concurrently.
var playerTask = LoadPlayerAsync();
var itemTask = LoadItemAsync();
var questTask = LoadQuestAsync();
var (player, item, quest) = await UniTask.WhenAll(
playerTask,
itemTask,
questTask
);
In game loading code, this can directly affect loading time.
If you want to process results as they complete, WhenEach is also useful.
var tasks = new[]
{
LoadMasterAsync("player"),
LoadMasterAsync("item"),
LoadMasterAsync("quest"),
};
await foreach (var result in UniTask.WhenEach(tasks))
{
var master = result.GetResult();
ApplyMaster(master);
}
WhenEach is useful when you want to apply results in completion order instead of waiting for everything first.
If you want to handle errors individually, you can inspect each result.
await foreach (var result in UniTask.WhenEach(tasks))
{
if (result.IsFaulted)
{
Debug.LogException(result.Exception);
continue;
}
ApplyMaster(result.GetResult());
}
This is where Awaitable's characteristics matter.
Awaitable instances are pooled internally, and you should not await the same Awaitable instance more than once.
Awaiting the same instance multiple times can lead to exceptions, deadlocks, or behavior close to undefined behavior.
Also, standard Awaitable does not provide the same level of composition APIs as UniTask's WhenAll, WhenAny, and WhenEach.
So Awaitable is convenient for lightweight coroutine replacement, but UniTask is more practical when you need to manage many loading operations or network requests together.
UniTask strength 2: Fine-grained PlayerLoopTiming control
In Unity async code, simply returning to the main thread is not always enough.
Sometimes you need to care about where in the PlayerLoop you resume.
For example:
Resume before Update
Resume after LateUpdate
Run at FixedUpdate timing
Run at an EndOfFrame-like timing
UniTask lets you specify PlayerLoopTiming.
await UniTask.Yield(PlayerLoopTiming.Update);
await UniTask.Yield(PlayerLoopTiming.LastPostLateUpdate);
You can also specify timing when switching back to the main thread.
await UniTask.SwitchToMainThread(PlayerLoopTiming.PreLateUpdate);
This can matter for code such as:
UI updates
Camera post-processing
Code that depends on Transform updates
Loading progress display
Screenshots at the end of the frame
Post-processing after Addressables or AsyncOperation completion
Awaitable has NextFrameAsync, FixedUpdateAsync, and EndOfFrameAsync.
However, it does not expose PlayerLoopTiming as finely as UniTask.
UniTask's PlayerLoopTiming includes many phases such as Initialization, EarlyUpdate, FixedUpdate, PreUpdate, Update, PreLateUpdate, PostLateUpdate, and LastPostLateUpdate.
So the practical distinction is:
If returning to the main thread is enough: Awaitable can be enough
If the exact PlayerLoop timing matters: UniTask is stronger
UniTask strength 3: Yield and NextFrame can be used intentionally
A subtle but important UniTask detail is the difference between Yield and NextFrame.
await UniTask.Yield();
and:
await UniTask.NextFrame();
do not mean exactly the same thing.
If your intent is "wait until the next frame" in the same sense as yield return null, then UniTask.NextFrame() usually expresses that intent more clearly.
await UniTask.NextFrame();
On the other hand, UniTask.Yield() resumes at the specified PlayerLoopTiming.
Depending on when it is called, it may resume within the same frame.
So if you casually use Yield as a replacement for yield return null, it can cause bugs in code that depends on frame boundaries.
This is easy to miss in basic tutorials.
In asynchronous code, it is not enough to say "wait."
Sometimes it matters when the continuation resumes.
UniTask strength 4: Cancellation is easier to operate in practice
In games, an async operation often outlives the object that started it.
For example:
The scene changed
The screen was closed
The prefab was destroyed
A network request was canceled
The player returned to the title screen during loading
If the async operation keeps running, it may later try to touch an object that has already been destroyed.
UniTask makes common Unity cancellation patterns easy to write.
private async UniTaskVoid Start()
{
var token = this.GetCancellationTokenOnDestroy();
try
{
await LoadAsync(token);
}
catch (OperationCanceledException)
{
// Cancellation caused by Destroy, screen transition, etc.
}
}
private async UniTask LoadAsync(CancellationToken cancellationToken)
{
await UniTask.Delay(1000, cancellationToken: cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
// Touch Unity APIs
label.text = "Loaded";
}
Awaitable APIs can also accept CancellationToken.
However, UniTask provides many practical patterns that fit Unity development very well.
In particular, GetCancellationTokenOnDestroy() makes it easy to tie the lifetime of an async operation to the lifetime of a MonoBehaviour.
UniTask strength 5: UniTaskTracker helps find forgotten operations
Asynchronous operations are easy to leave behind invisibly.
For example:
SomeAsync().Forget();
Forget() is convenient, but if used carelessly, the operation may continue running even after the screen has closed.
Loading, networking, polling, and UI effects can keep running and produce mysterious logs or exceptions later.
UniTask includes UniTaskTracker.
This is very useful in practice.
Async problems are not always about the speed of a single operation.
Often the real issue is:
An operation that should have ended is still running
An operation that should have been canceled is still active
The same async operation was started multiple times
Awaitable does not have an equivalent tracker.
This is another reason UniTask remains strong as an application-wide async foundation.
A common allocation trap around Progress
Even if you use UniTask, surrounding code can still allocate.
A typical example is Progress<T>.
var progress = new Progress<float>(x =>
{
progressBar.value = x;
});
This is convenient, but if created frequently, it can become a source of allocations.
UniTask provides Progress.Create.
var progress = Progress.Create<float>(x =>
{
progressBar.value = x;
});
For very high-frequency progress reporting, you can also implement IProgress<T> yourself and reuse the instance.
public sealed class ProgressBarReporter : IProgress<float>
{
private readonly UnityEngine.UI.Slider slider;
public ProgressBarReporter(UnityEngine.UI.Slider slider)
{
this.slider = slider;
}
public void Report(float value)
{
slider.value = value;
}
}
var progress = new ProgressBarReporter(progressBar);
await LoadAsync(progress, cancellationToken);
When you see allocations while using UniTask, the cause may not be UniTask itself.
It may be Progress, lambdas, closures, LINQ, array allocations, or other surrounding code.
So it is important to look at the whole implementation, not just whether UniTask is being used.
Moving to the ThreadPool does not automatically make loading faster
This is another common misunderstanding.
await Awaitable.BackgroundThreadAsync();
or:
await UniTask.SwitchToThreadPool();
can move heavy work to a background thread.
However, Unity loading itself does not necessarily become fully background-threaded just because you moved your continuation.
For example, Addressables, AssetBundle loading, scene loading, texture creation, and mesh creation may still include main-thread work internally.
So you should not assume:
I moved to BackgroundThread, so all Unity loading no longer blocks the main thread.
Moving to a background thread is most effective for work that does not depend on Unity APIs.
For example:
JSON parsing
MessagePack parsing
Custom binary parsing
Encryption / decryption
Compression / decompression
Hash calculation
Large array processing
Pure computation such as pathfinding or scoring
On the other hand, creating UnityEngine.Object, touching Transform, or loading scenes still requires main-thread work at some point.
This boundary matters.
Also, if you target WebGL or web builds, be careful with designs that depend on ThreadPool.
The UniTask README explains that APIs such as UniTask.SwitchToThreadPool() and UniTask.RunOnThreadPool() do not work on WebGL and similar environments.
If web builds are part of your target, avoid depending too heavily on ThreadPool-based designs. Instead, consider splitting work across frames, using coroutine or PlayerLoop-based distribution, or checking the current state of Job System support for your target.
A safer pattern for using ThreadPool with Awaitable
When using Awaitable for background work, pay attention to which thread the method completes on.
For example, the following code needs caution:
private async Awaitable<ParsedData> ParseAsync(byte[] bytes)
{
await Awaitable.BackgroundThreadAsync();
var parsed = ParseBytes(bytes);
return parsed;
}
This method does its work on a background thread and then completes there.
If the caller immediately touches Unity APIs after awaiting it, that can be dangerous.
var data = await ParseAsync(bytes);
// If ParseAsync completes on a background thread,
// touching Unity APIs here is dangerous.
gameObject.name = data.Name;
If the caller may touch Unity APIs after this method returns, a safer default is to return to the main thread before completing the method.
private async Awaitable<ParsedData> ParseAsync(
byte[] bytes,
bool continueOnMainThread = true)
{
await Awaitable.BackgroundThreadAsync();
var parsed = ParseBytes(bytes);
if (continueOnMainThread)
{
await Awaitable.MainThreadAsync();
}
return parsed;
}
With this pattern, normal calls complete after returning to the main thread.
var data = await ParseAsync(bytes);
// ParseAsync has returned to the main thread before completion,
// so touching Unity APIs is easier to reason about.
gameObject.name = data.Name;
If you want to chain multiple heavy operations on a background thread, you can opt out explicitly.
var data = await ParseAsync(bytes, continueOnMainThread: false);
// If you want to touch Unity APIs here,
// explicitly return to the main thread.
await Awaitable.MainThreadAsync();
gameObject.name = data.Name;
The important point is to make the contract clear:
Which thread does this async method complete on?
Is it safe to touch Unity APIs after awaiting it?
How far does cancellation propagate?
Also, Unity does not necessarily stop background operations automatically when exiting Play Mode.
When using background work in the Editor, consider using Application.exitCancellationToken so the operation can be canceled when Play Mode ends.
Using RunOnThreadPool with UniTask
With UniTask, you can also write background execution that returns a result like this:
var parsed = await UniTask.RunOnThreadPool(() =>
{
return ParseBytes(bytes);
});
ApplyToUnityObject(parsed);
The same warning applies here.
Do not touch Unity APIs inside ParseBytes.
var parsed = await UniTask.RunOnThreadPool(() =>
{
// NG
var position = transform.position;
return ParseBytes(bytes);
});
Code running on the ThreadPool should be limited to pure work that does not depend on Unity APIs.
For library development, returning Awaitable can make sense
For application code, UniTask is still very useful.
However, for libraries or Unity Asset Store packages, you may want to avoid adding external dependencies.
If you can target Unity 6 or later, returning Awaitable can be a realistic choice.
public async Awaitable LoadAsync(CancellationToken cancellationToken)
{
await Awaitable.NextFrameAsync(cancellationToken);
// Work
}
If the application uses UniTask, it can adapt Awaitable as needed.
Library side: return Awaitable
Application side: convert or wrap it for UniTask-based operation
This keeps the library dependency-light while still allowing the application to use UniTask's operational features.
UniTask can handle Awaitable with AsUniTask(), so the two do not need to be treated as mutually exclusive.
Practical guideline
Here is a practical rule of thumb.
| Case | Awaitable | UniTask |
|---|---|---|
| Small waiting logic | Good fit | Also possible |
| Lightweight coroutine replacement | Good fit | Also possible |
| Standard Unity-only implementation | Good fit | Adds external dependency |
| Library return type | Good fit | Requires users to depend on UniTask |
| Waiting for multiple loads together | Weak | Good fit |
| Managing many async operations | Weak | Good fit |
| Designing cancellation across systems | Possible | Good fit |
| Fine-grained PlayerLoopTiming control | Weak | Good fit |
| Finding forgotten async operations | Weak | Good fit |
| Considering WebGL / ThreadPool limitations | Be careful | Be careful |
The important point is that Awaitable and UniTask are not enemies.
Awaitable is Unity's lightweight standard async API.
UniTask is a practical tool for managing larger async workflows in Unity applications.
Summary
Awaitable made asynchronous programming in Unity much more practical.
The biggest changes are that Unity now provides standard APIs for:
Waiting until the next frame
Waiting for seconds
Waiting for FixedUpdate
Waiting until EndOfFrame
Awaiting AsyncOperation
Switching to the main thread
Switching to a background thread
Because of this, simple coroutine replacements and small async workflows can often be written with Awaitable alone.
However, real game development rarely stops at single async operations.
You often need to handle:
Large-scale loading
Networking
Addressables
Cancellation
Screen transitions
Destroy-linked lifetimes
Exception handling
Progress reporting
PlayerLoopTiming control
Tracking forgotten operations
Once you consider these operational concerns, UniTask still has a strong reason to exist.
The conclusion is:
Awaitable made Unity-standard async/await much more realistic.
But if you need to operate asynchronous workflows in a real application, UniTask is still powerful.
In the Unity 6 era, UniTask is no longer just a coroutine replacement.
It is better understood as a foundation for organizing asynchronous workflows across an application.
References
Unity Manual: Introduction to asynchronous programming with Awaitable
https://docs.unity3d.com/6000.1/Documentation/Manual/async-awaitable-introduction.htmlUnity Manual: Awaitable completion and continuation
https://docs.unity3d.com/6000.1/Documentation/Manual/async-awaitable-continuations.htmlUnity Manual: Awaitable completion and continuation (Unity 6000.0)
https://docs.unity3d.com/6000.0/Documentation/Manual/async-awaitable-continuations.htmlUnity Scripting API: Awaitable
https://docs.unity3d.com/6000.2/Documentation/ScriptReference/Awaitable.htmlUniTask GitHub README
https://github.com/Cysharp/UniTask











