unity-async-patterns

Installation
SKILL.md

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; // First await -- works
await task; // Second await -- UNDEFINED BEHAVIOR (may complete instantly or throw)

RIGHT:

// Awaitable is POOLED -- after the first await completes, the instance is recycled
// Each Awaitable should be awaited exactly once

// If you need to await the same operation from multiple places, use .AsTask():
var task = Awaitable.WaitForSecondsAsync(2f).AsTask();
await task; // Works
await task; // Works -- Task is not pooled

// Or simply create separate Awaitables:
await Awaitable.WaitForSecondsAsync(2f);
await Awaitable.WaitForSecondsAsync(2f); // Fresh instance

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);
    // If object was destroyed during the wait:
    // - MissingReferenceException on next Unity API call
    // - Or worse: silently operates on a "fake-null" object
    transform.position = Vector3.zero;
}

RIGHT:

async Awaitable Start()
{
    try
    {
        await Awaitable.WaitForSecondsAsync(5f, destroyCancellationToken);
        transform.position = Vector3.zero;
    }
    catch (OperationCanceledException)
    {
        // Object was destroyed -- this is expected, not an error
    }
}

// For methods that chain multiple awaits:
async Awaitable DoMultiStepWork()
{
    var token = destroyCancellationToken;

    await Awaitable.NextFrameAsync(token);
    ProcessStep1();

    await Awaitable.WaitForSecondsAsync(1f, token);
    ProcessStep2(); // Safe: would have thrown before reaching here if destroyed

    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(); // OK: runs on background thread

    // CRASH: Accessing Unity API from background thread
    transform.position = new Vector3(result, 0, 0);
}

RIGHT:

async Awaitable ProcessData()
{
    await Awaitable.BackgroundThreadAsync();
    var result = HeavyComputation(); // Runs on background thread

    await Awaitable.MainThreadAsync(); // Switch BACK to main thread
    transform.position = new Vector3(result, 0, 0); // Now safe

    // Can switch back and forth:
    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(); // If this throws, coroutine silently stops
    ProcessData();           // Never reached, no error in console (or just a log, no stack)
}

// try/catch doesn't work with yield:
IEnumerator BadErrorHandling()
{
    try
    {
        yield return SomethingDangerous(); // COMPILER ERROR: cannot yield in try block with catch
    }
    catch (Exception e)
    {
        Debug.LogError(e);
    }
}

RIGHT:

// Option 1: Use Awaitable instead (proper exception propagation)
async Awaitable LoadAndProcess()
{
    try
    {
        await LoadDataAsync();
        ProcessData();
    }
    catch (Exception e)
    {
        Debug.LogError($"Load failed: {e}");
    }
}

// Option 2: Error handling without yield in the try block
IEnumerator LoadAndProcessCoroutine()
{
    bool success = false;
    Exception error = null;

    // Wrap the yield outside try/catch
    yield return LoadDataCoroutine(result =>
    {
        success = true;
    });

    // Handle errors after the yield
    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(); // HANGS in batch mode (no rendering)
    var tex = ScreenCapture.CaptureScreenshotAsTexture();
}

// Same issue with Awaitable:
async Awaitable WaitForRender()
{
    await Awaitable.EndOfFrameAsync(); // HANGS in batch mode
}

RIGHT:

IEnumerator CaptureScreenshot()
{
    // Check if we're in batch mode
    if (Application.isBatchMode)
    {
        yield return null; // Just wait one frame instead
        Debug.LogWarning("Screenshot not available in batch mode");
        yield break;
    }

    yield return new WaitForEndOfFrame();
    var tex = ScreenCapture.CaptureScreenshotAsTexture();
}

// For tests that need frame advancement without rendering:
IEnumerator TestCoroutine()
{
    yield return null; // Advances one frame (works in all modes)
    // yield return new WaitForFixedUpdate(); // Also works in batch mode
}

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()); // Launched independently
    StartCoroutine(SubTaskB()); // Launched independently
    yield return new WaitForSeconds(10f);
}

void Cancel()
{
    StopCoroutine(_mainRoutine);
    // SubTaskA and SubTaskB continue running!
}

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()
{
    // Must stop each coroutine individually
    if (_mainRoutine != null) StopCoroutine(_mainRoutine);
    if (_subA != null) StopCoroutine(_subA);
    if (_subB != null) StopCoroutine(_subB);
}

// Better: yield return child coroutines (parent owns them)
IEnumerator MainLoopBetter()
{
    yield return StartCoroutine(SubTaskA()); // Waits for A, then...
    yield return StartCoroutine(SubTaskB()); // Waits for B
    // Stopping MainLoopBetter also stops the currently-yielded child
}

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: exceptions crash the application with no way to catch them
async void DoWork()
{
    await Awaitable.WaitForSecondsAsync(1f);
    throw new Exception("oops"); // UNHANDLED -- crashes the app
}

void Start()
{
    DoWork(); // No way to catch the exception from here
}

RIGHT:

// async Awaitable: proper exception propagation
async Awaitable DoWork()
{
    await Awaitable.WaitForSecondsAsync(1f);
    throw new Exception("oops"); // Propagates to caller
}

async Awaitable Start()
{
    try
    {
        await DoWork(); // Exception caught here
    }
    catch (Exception e)
    {
        Debug.LogError($"Work failed: {e.Message}");
    }
}

// async void is ONLY acceptable for Unity event handlers that require void:
// - Button.onClick handlers
// - UnityEvent callbacks
// Even then, wrap the body in try/catch:
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):

// Two async methods writing to the same field
async Awaitable OnClickSearch(string query)
{
    var results = await SearchAsync(query); // User types "cat"
    _displayedResults = results;            // Race: which query wins?
}
// User clicks twice quickly: "cat" then "dog"
// If "dog" returns first, "cat" results overwrite "dog" results

RIGHT:

private CancellationTokenSource _searchCts;

async Awaitable OnClickSearch(string query)
{
    // Cancel the previous search
    _searchCts?.Cancel();
    _searchCts?.Dispose();
    _searchCts = new CancellationTokenSource();
    var token = _searchCts.Token;

    try
    {
        var results = await SearchAsync(query, token);
        token.ThrowIfCancellationRequested(); // Check before applying
        _displayedResults = results;          // Only the latest search applies
    }
    catch (OperationCanceledException)
    {
        // Previous search cancelled -- expected
    }
}

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);
    // Handle never released -- memory leak!
}

RIGHT:

private AsyncOperationHandle<GameObject> _enemyHandle;

async Awaitable LoadEnemy()
{
    _enemyHandle = Addressables.LoadAssetAsync<GameObject>("enemy_prefab");
    var prefab = await _enemyHandle.Task;
    Instantiate(prefab);
}

void OnDestroy()
{
    // Release when no longer needed
    if (_enemyHandle.IsValid())
        Addressables.Release(_enemyHandle);
}

// For instantiated objects, use Addressables.InstantiateAsync (auto-tracked):
async Awaitable SpawnEnemy()
{
    var handle = Addressables.InstantiateAsync("enemy_prefab", spawnPoint.position, Quaternion.identity);
    var instance = await handle.Task;
    // When done: Addressables.ReleaseInstance(instance) instead of Destroy
}

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):

// Mixing UniTask and Awaitable in the same method
async UniTask DoWork()
{
    await Awaitable.NextFrameAsync(); // Type mismatch: Awaitable in UniTask method
}

RIGHT:

// Pick ONE async framework per project:

// === Option A: Awaitable (Unity 6+ built-in) ===
// Pros: No dependencies, integrated with Unity lifecycle, pooled (zero-alloc)
// Cons: Limited utilities (no WhenAll, WhenAny, no channel/queue)
async Awaitable DoWorkAwaitable()
{
    await Awaitable.NextFrameAsync(destroyCancellationToken);
    await Awaitable.WaitForSecondsAsync(1f, destroyCancellationToken);
}

// === Option B: UniTask (third-party: com.cysharp.unitask) ===
// Pros: Rich API (WhenAll, WhenAny, channels), PlayerLoop integration, zero-alloc
// Cons: External dependency, must learn UniTask-specific patterns
async UniTask DoWorkUniTask()
{
    await UniTask.NextFrame(cancellationToken: destroyCancellationToken);
    await UniTask.Delay(1000, cancellationToken: destroyCancellationToken);
    // UniTask extras: WhenAll, WhenAny, Channel, AsyncReactiveProperty
}

// Converting between them (if mixing is unavoidable):
// Awaitable -> UniTask: not directly; use .AsTask() as bridge
// UniTask -> Awaitable: not directly; use .AsTask() as bridge

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

Weekly Installs
7
GitHub Stars
8
First Seen
Mar 19, 2026
Installed on
amp6
cline6
opencode6
cursor6
kimi-cli6
warp6