| name | unity-async-patterns |
| description | Unity async and coroutine correctness patterns. Catches common mistakes with Awaitable double-await, missing cancellation tokens, thread context after BackgroundThreadAsync, coroutine error swallowing, batch mode WaitForEndOfFrame, and Addressables handle leaks. PATTERN format: WHEN/WRONG/RIGHT/GOTCHA. Based on Unity 6.3 LTS documentation.
|
| globs | ["**/*.cs"] |
Async & Coroutine Patterns -- Correctness Patterns
Prerequisite skills: unity-scripting (coroutines, Awaitable API, yield types), unity-lifecycle (destruction timing, destroyCancellationToken)
These patterns target async bugs that are especially dangerous because they often work during testing and fail in production: exceptions silently swallowed, objects destroyed mid-await, and thread context violations.
PATTERN: Awaitable Double-Await
WHEN: Storing an Awaitable instance and awaiting it more than once
WRONG (Claude default):
Awaitable task = Awaitable.WaitForSecondsAsync(2f);
await task;
await task;
RIGHT:
var task = Awaitable.WaitForSecondsAsync(2f).AsTask();
await task;
await task;
await Awaitable.WaitForSecondsAsync(2f);
await Awaitable.WaitForSecondsAsync(2f);
GOTCHA: Unity pools Awaitable instances to avoid allocation. After completion, the instance is returned to the pool and may be reused by a completely different operation. A second await on the same instance may see a different operation's state, complete instantly, or throw. This is unlike Task which can be safely awaited multiple times. Use .AsTask() when you need multi-await semantics, but be aware this allocates.
PATTERN: Missing destroyCancellationToken
WHEN: Writing async methods in MonoBehaviours
WRONG (Claude default):
async Awaitable Start()
{
await Awaitable.WaitForSecondsAsync(5f);
transform.position = Vector3.zero;
}
RIGHT:
async Awaitable Start()
{
try
{
await Awaitable.WaitForSecondsAsync(5f, destroyCancellationToken);
transform.position = Vector3.zero;
}
catch (OperationCanceledException)
{
}
}
async Awaitable DoMultiStepWork()
{
var token = destroyCancellationToken;
await Awaitable.NextFrameAsync(token);
ProcessStep1();
await Awaitable.WaitForSecondsAsync(1f, token);
ProcessStep2();
await LoadAssetAsync(token);
ProcessStep3();
}
GOTCHA: destroyCancellationToken is a property on MonoBehaviour that triggers when OnDestroy begins. Every Awaitable wait method accepts an optional CancellationToken. Without it, the await completes normally even after the object is destroyed, leading to MissingReferenceException. Always pass the token AND catch OperationCanceledException.
PATTERN: Thread Context After BackgroundThreadAsync
WHEN: Returning to Unity APIs after doing work on a background thread
WRONG (Claude default):
async Awaitable ProcessData()
{
await Awaitable.BackgroundThreadAsync();
var result = HeavyComputation();
transform.position = new Vector3(result, 0, 0);
}
RIGHT:
async Awaitable ProcessData()
{
await Awaitable.BackgroundThreadAsync();
var result = HeavyComputation();
await Awaitable.MainThreadAsync();
transform.position = new Vector3(result, 0, 0);
await Awaitable.BackgroundThreadAsync();
var moreData = AnotherHeavyTask();
await Awaitable.MainThreadAsync();
ApplyResults(moreData);
}
GOTCHA: After BackgroundThreadAsync(), ALL subsequent code runs on a thread pool thread until you explicitly switch back with MainThreadAsync(). Unity APIs (Transform, GameObject, Physics, etc.) are not thread-safe and will throw or corrupt state if called from a background thread. MainThreadAsync() resumes on the next frame's player loop update, not immediately.
PATTERN: Coroutine Error Swallowing
WHEN: Exceptions occur inside coroutines
WRONG (Claude default):
IEnumerator LoadAndProcess()
{
yield return LoadData();
ProcessData();
}
IEnumerator BadErrorHandling()
{
try
{
yield return SomethingDangerous();
}
catch (Exception e)
{
Debug.LogError(e);
}
}
RIGHT:
async Awaitable LoadAndProcess()
{
try
{
await LoadDataAsync();
ProcessData();
}
catch (Exception e)
{
Debug.LogError($"Load failed: {e}");
}
}
IEnumerator LoadAndProcessCoroutine()
{
bool success = false;
Exception error = null;
yield return LoadDataCoroutine(result =>
{
success = true;
});
if (!success)
{
Debug.LogError("Load failed");
yield break;
}
ProcessData();
}
GOTCHA: In coroutines, yield return cannot appear inside a try block that has a catch clause (C# language restriction). Exceptions in yielded coroutines are logged to the console but execution silently stops -- no propagation to the caller. The caller's coroutine continues as if the nested one completed. Use Awaitable for any operation that can fail and needs error handling.
PATTERN: WaitForEndOfFrame in Batch Mode
WHEN: Using WaitForEndOfFrame or Awaitable.EndOfFrameAsync in headless/server/test environments
WRONG (Claude default):
IEnumerator CaptureScreenshot()
{
yield return new WaitForEndOfFrame();
var tex = ScreenCapture.CaptureScreenshotAsTexture();
}
async Awaitable WaitForRender()
{
await Awaitable.EndOfFrameAsync();
}
RIGHT:
IEnumerator CaptureScreenshot()
{
if (Application.isBatchMode)
{
yield return null;
Debug.LogWarning("Screenshot not available in batch mode");
yield break;
}
yield return new WaitForEndOfFrame();
var tex = ScreenCapture.CaptureScreenshotAsTexture();
}
IEnumerator TestCoroutine()
{
yield return null;
}
GOTCHA: WaitForEndOfFrame and EndOfFrameAsync wait for the rendering phase. In batch mode (-batchmode flag), headless servers, and some test runners, there is no rendering -- so these yields never complete and the coroutine/async hangs forever. Use yield return null (next Update) or Awaitable.NextFrameAsync() for frame advancement that works everywhere.
PATTERN: Nested Coroutine Cancellation
WHEN: Stopping a parent coroutine that launched child coroutines
WRONG (Claude default):
Coroutine _mainRoutine;
void Start()
{
_mainRoutine = StartCoroutine(MainLoop());
}
IEnumerator MainLoop()
{
StartCoroutine(SubTaskA());
StartCoroutine(SubTaskB());
yield return new WaitForSeconds(10f);
}
void Cancel()
{
StopCoroutine(_mainRoutine);
}
RIGHT:
private Coroutine _mainRoutine;
private Coroutine _subA;
private Coroutine _subB;
IEnumerator MainLoop()
{
_subA = StartCoroutine(SubTaskA());
_subB = StartCoroutine(SubTaskB());
yield return new WaitForSeconds(10f);
}
void Cancel()
{
if (_mainRoutine != null) StopCoroutine(_mainRoutine);
if (_subA != null) StopCoroutine(_subA);
if (_subB != null) StopCoroutine(_subB);
}
IEnumerator MainLoopBetter()
{
yield return StartCoroutine(SubTaskA());
yield return StartCoroutine(SubTaskB());
}
GOTCHA: StartCoroutine(SubTask()) launches an independent coroutine. StopCoroutine only stops the specified coroutine. BUT: yield return StartCoroutine(SubTask()) makes the parent wait for the child, and stopping the parent also stops the yielded child. The key distinction: StartCoroutine without yield return = fire-and-forget; with yield return = owned by parent. For complex cancellation trees, prefer Awaitable with CancellationToken.
PATTERN: async void vs async Awaitable
WHEN: Declaring async methods in Unity scripts
WRONG (Claude default):
async void DoWork()
{
await Awaitable.WaitForSecondsAsync(1f);
throw new Exception("oops");
}
void Start()
{
DoWork();
}
RIGHT:
async Awaitable DoWork()
{
await Awaitable.WaitForSecondsAsync(1f);
throw new Exception("oops");
}
async Awaitable Start()
{
try
{
await DoWork();
}
catch (Exception e)
{
Debug.LogError($"Work failed: {e.Message}");
}
}
async void OnButtonClicked()
{
try
{
await SaveGameAsync();
}
catch (Exception e)
{
Debug.LogError(e);
}
}
GOTCHA: async void methods propagate exceptions to the SynchronizationContext, which in Unity logs them and potentially crashes. async Awaitable methods propagate exceptions to the awaiter, allowing proper try/catch. Unity's lifecycle methods (Start, OnEnable, etc.) can return Awaitable -- prefer this over void when using async.
PATTERN: Concurrent Awaitable Race Conditions
WHEN: Multiple async operations modify shared state
WRONG (Claude default):
async Awaitable OnClickSearch(string query)
{
var results = await SearchAsync(query);
_displayedResults = results;
}
RIGHT:
private CancellationTokenSource _searchCts;
async Awaitable OnClickSearch(string query)
{
_searchCts?.Cancel();
_searchCts?.Dispose();
_searchCts = new CancellationTokenSource();
var token = _searchCts.Token;
try
{
var results = await SearchAsync(query, token);
token.ThrowIfCancellationRequested();
_displayedResults = results;
}
catch (OperationCanceledException)
{
}
}
void OnDestroy()
{
_searchCts?.Cancel();
_searchCts?.Dispose();
}
GOTCHA: Unlike coroutines (which are single-threaded and frame-sequential), multiple Awaitable chains can interleave across frames. The cancel-previous pattern ensures only the most recent operation applies its results. Link the CancellationTokenSource token with destroyCancellationToken using CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken) for automatic cleanup on destroy.
PATTERN: Addressables AsyncOperationHandle Leak
WHEN: Loading assets with Addressables and not releasing them
WRONG (Claude default):
async Awaitable LoadEnemy()
{
var handle = Addressables.LoadAssetAsync<GameObject>("enemy_prefab");
var prefab = await handle.Task;
Instantiate(prefab);
}
RIGHT:
private AsyncOperationHandle<GameObject> _enemyHandle;
async Awaitable LoadEnemy()
{
_enemyHandle = Addressables.LoadAssetAsync<GameObject>("enemy_prefab");
var prefab = await _enemyHandle.Task;
Instantiate(prefab);
}
void OnDestroy()
{
if (_enemyHandle.IsValid())
Addressables.Release(_enemyHandle);
}
async Awaitable SpawnEnemy()
{
var handle = Addressables.InstantiateAsync("enemy_prefab", spawnPoint.position, Quaternion.identity);
var instance = await handle.Task;
}
GOTCHA: Every Addressables.LoadAssetAsync call increments a reference count. Without Addressables.Release, the asset stays in memory forever. Addressables.InstantiateAsync tracks instances automatically -- use Addressables.ReleaseInstance instead of Destroy. Scene loading with Addressables (LoadSceneAsync) auto-releases on scene unload. Releasing a handle with active instances may cause pink/missing material rendering.
PATTERN: UniTask vs Awaitable Selection
WHEN: Choosing an async framework for a Unity project
WRONG (Claude default):
async UniTask DoWork()
{
await Awaitable.NextFrameAsync();
}
RIGHT:
async Awaitable DoWorkAwaitable()
{
await Awaitable.NextFrameAsync(destroyCancellationToken);
await Awaitable.WaitForSecondsAsync(1f, destroyCancellationToken);
}
async UniTask DoWorkUniTask()
{
await UniTask.NextFrame(cancellationToken: destroyCancellationToken);
await UniTask.Delay(1000, cancellationToken: destroyCancellationToken);
}
GOTCHA: Awaitable is built into Unity 6+ and requires no packages. UniTask (com.cysharp.unitask) is a mature third-party library with richer functionality. Do NOT mix both in the same codebase without a clear boundary -- their cancellation patterns, pooling behavior, and PlayerLoop integration differ. If targeting Unity 6+, Awaitable covers most needs. Use UniTask if you need advanced patterns like WhenAll, async LINQ, or IUniTaskAsyncEnumerable.
Anti-Patterns Quick Reference
| Anti-Pattern | Problem | Fix |
|---|
await Task.Delay() in Unity | Ignores TimeScale, no frame sync | Use Awaitable.WaitForSecondsAsync() |
Task.Run() for Unity computation | Thread pool with no main thread return | Use Awaitable.BackgroundThreadAsync() + MainThreadAsync() |
StopAllCoroutines() as cleanup | Nuclear option; stops coroutines you didn't start | Track and stop specific coroutines |
Ignoring return value of StartCoroutine | Cannot cancel later | Store the Coroutine reference |
yield return new WaitForSeconds(0) | Unclear intent, allocates | Use yield return null (no allocation) |
async Task methods in MonoBehaviour | Task exceptions lost, no destroyCancellationToken integration | Use async Awaitable |
Related Skills
- unity-scripting -- Coroutine fundamentals, Awaitable API reference, yield types
- unity-lifecycle -- destroyCancellationToken, object destruction timing
- unity-performance -- Async profiling, allocation tracking
Additional Resources