Skip to content
Back to skills

Unity Async Patterns

ASecurity

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.

  • 17 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 27, 2026
data-aigoc#reacttestinggitapiperformancedocumentation

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned May 27, 2026

npx -y skills add IdoCohen560/claude-unity-game-studio --skill unity-async-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Unity Async Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Unity Async Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/idocohen560-unity-async-patterns/badge)](https://www.skillsdirectory.com/skills/idocohen560-unity-async-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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):
```csharp
Awaitable task = Awaitable.WaitForSecondsAsync(2f);
await task; // First await -- works
await task; // Second await -- UNDEFINED BEHAVIOR (may complete instantly or throw)
```

RIGHT:
```csharp
// 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):
```csharp
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:
```csharp
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):
```csharp
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:
```csharp
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):
```csharp
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:
```csharp
// 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):
```csharp
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:
```csharp
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):
```csharp
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:
```csharp
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):
```csharp
// 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:
```csharp
// 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):
```csharp
// 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:
```csharp
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):
```csharp
async Awaitable LoadEnemy()
{
    var handle = Addressables.LoadAssetAsync<GameObject>("enemy_prefab");
    var prefab = await handle.Task;
    Instantiate(prefab);
    // Handle never released -- memory leak!
}
```

RIGHT:
```csharp
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):
```csharp
// Mixing UniTask and Awaitable in the same method
async UniTask DoWork()
{
    await Awaitable.NextFrameAsync(); // Type mismatch: Awaitable in UniTask method
}
```

RIGHT:
```csharp
// 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

- [Awaitable API](https://docs.unity3d.com/6000.3/Documentation/ScriptReference/Awaitable.html)
- [Coroutines Manual](https://docs.unity3d.com/6000.3/Documentation/Manual/Coroutines.html)
- [Addressables AsyncOperationHandle](https://docs.unity3d.com/Packages/com.unity.addressables@2.3/manual/index.html)
- [UniTask GitHub](https://github.com/Cysharp/UniTask)

Files in this skill

  • SKILL.md17.2 KB
  • references/async-cancellation-patterns.md9.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.