Quick answer: Move long-lived coroutines to a persistent CoroutineRunner singleton, or convert them to async/await with a CancellationToken. Coroutines die with their host MonoBehaviour.

A pickup spawns, you call StartCoroutine(FadeOutAndDestroy()), and the SetActive(false) call inside the coroutine never runs because the GameObject was disabled by a pooling system before the coroutine reached its next yield. The coroutine is silently terminated. The object stays at its half-faded state with no logged error.

Why Unity Coroutines Are Tied to MonoBehaviours

Every coroutine runs in the context of the MonoBehaviour that called StartCoroutine. The MonoBehaviour’s lifecycle drives the coroutine’s lifecycle:

This is by design — it prevents coroutines from continuing to mutate a destroyed object — but it’s a frequent source of bugs when a coroutine’s logical lifetime exceeds its host’s.

Fix 1: Persistent Coroutine Runner

public class CoroutineRunner : MonoBehaviour
{
    public static CoroutineRunner Instance { get; private set; }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Init()
    {
        var go = new GameObject("CoroutineRunner");
        DontDestroyOnLoad(go);
        Instance = go.AddComponent<CoroutineRunner>();
    }
}

Use it from anywhere that wants a coroutine to outlive its caller:

CoroutineRunner.Instance.StartCoroutine(FadeOutAndDestroy(target));

Because the runner’s GameObject is in DontDestroyOnLoad and never disabled, the coroutine survives scene changes, pooling, and host disables. The coroutine itself takes the target as a parameter and checks for null at each step.

Fix 2: async/await with CancellationToken

Coroutines pre-date async/await in Unity, but modern Unity supports the latter natively (and better with UniTask):

using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class Pickup : MonoBehaviour
{
    CancellationTokenSource cts;

    void OnEnable() => cts = new CancellationTokenSource();

    void OnDisable() => cts.Cancel();

    public async Task FadeOut(float duration)
    {
        float t = 0;
        while (t < duration)
        {
            cts.Token.ThrowIfCancellationRequested();
            t += Time.deltaTime;
            // fade logic
            await Task.Yield();
        }
        Destroy(gameObject);
    }
}

The explicit ThrowIfCancellationRequested means cancellation is observable and you can wrap it in try/catch. With UniTask, replace Task with UniTask and avoid the SynchronizationContext overhead of plain Task.

Fix 3: External Coroutine on a Sibling

If you don’t want a global runner but still want the coroutine to outlive a specific MonoBehaviour, call it on a sibling that’s guaranteed to stay active — for example, the parent “Spawner” that owns the pickup. The pickup’s coroutine method becomes a static or instance method that accepts the pickup as a parameter, and the spawner starts it:

spawner.StartCoroutine(FadeOutAndDestroy(pickup));

The spawner remains active even when the pickup is disabled, so the coroutine continues.

What Not to Do

Don’t simply re-enable the GameObject mid-fade — pooled GameObjects intentionally disable themselves and re-enabling them confuses the pool. Don’t use StartCoroutine on a deactivated GameObject expecting it to run later — Unity logs an error and the coroutine never starts.

Verifying

Add a log at the end of the coroutine. Before the fix, the log only fires sometimes (when the host stayed active long enough). After moving to the runner or async/await, the log fires every time the coroutine’s logical sequence completes, regardless of what happens to the originating GameObject.

“Coroutines die with their host. If the work needs to outlive the host, the work doesn’t belong on the host.”

For object pools especially: never start “destroy in N seconds” coroutines on pooled objects — the pool will disable them mid-coroutine.