Quick answer: Avoid async void in Unity except for event handlers. Always await your tasks. Add a TaskScheduler.UnobservedTaskException handler at startup that logs to Unity. Or switch to UniTask which solves the lifetime and exception story for Unity properly.
An async method throws. Your game continues running like nothing happened. The exception is swallowed because the Task wasn’t awaited. C# fire-and-forget tasks default to silent failure.
The Symptom
Async logic appears to run but produces no output. No error in console. State is half-updated. Sometimes a generic “A Task’s exception(s) were not observed” message appears minutes later from a GC finalizer.
What Causes This
An async Task method that throws stores the exception in the Task object. If nothing observes the Task (await, .Wait(), .Result, exception handlers), the exception is invisible until the Task is GC’d, at which point TaskScheduler.UnobservedTaskException fires — which Unity doesn’t log by default.
async void is worse: it has no Task at all, so the exception goes to the SynchronizationContext — which in Unity 2020+ surfaces as an unhandled exception, but only sometimes.
The Fix
Step 1: Log unobserved exceptions globally.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void InstallTaskExceptionLogger()
{
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
Debug.LogError($"Unobserved task exception: {e.Exception}");
e.SetObserved();
};
}
Now any unobserved Task exception logs to Unity at GC time. Better than silent.
Step 2: Always await or fire-and-log.
// Bad
DoStuffAsync(); // fire and forget, silent on throw
// Good (caller is async)
await DoStuffAsync();
// Good (sync caller intentionally fire-and-forget)
_ = DoStuffAsync().ContinueWith(t =>
{
if (t.IsFaulted) Debug.LogError(t.Exception);
});
Step 3: Use UniTask for Unity. Install com.cysharp.unitask. Replace Task with UniTask:
async UniTask LoadLevel(CancellationToken ct)
{
var handle = Addressables.LoadAssetAsync<GameObject>("prefab");
await handle.WithCancellation(ct);
// continue
}
UniTask integrates with Unity’s PlayerLoop, doesn’t allocate, and gives you token-aware cancellation tied to GameObject lifetime via this.GetCancellationTokenOnDestroy().
async void Pitfalls
Reserve async void for Unity event handlers (UI button OnClick) where you can’t change the signature. Wrap the body in try/catch:
public async void OnButtonClick()
{
try { await SaveGame(); }
catch (Exception ex) { Debug.LogError(ex); }
}
Verifying
Throw deliberately in an async method. Without the global handler: silence. With it: error within a few frames. Switch to UniTask for cleaner cancellation and zero allocation.
“Await everything. Log unobserved. UniTask for serious code. Exceptions become visible.”
Related Issues
For coroutine stop, see StopCoroutine. For Burst managed allocation, see Burst BC1006.
Await. Observe. Cancel. Exceptions surface.