1. The Hook

Most async Rust examples look like this:

tokio::spawn(async move {
    do_work().await;
});

It works. It compiles. It runs.

Until one of those tasks panics.

tokio::spawn doesn't crash your program. It doesn't log anything. It doesn't tell anyone. The task just stops existing. The JoinHandle it returned — if you kept it — resolves to an Err, but only if something is polling it. Usually nothing is.

In a service with real background work — collectors, workers, schedulers — that’s not an edge case. That’s a system quietly losing pieces of itself while the health check still says 200 OK.

This is why I stopped scattering tokio::spawn across a production Rust application and switched to tokio::task::JoinSet for anything that needs supervision. I'll use a blockchain event indexer as the example. The blockchain part is incidental. The pattern applies to any async service with long-running tasks.

2. The Problem With Detached Tasks

tokio::spawn returns a JoinHandle<T> — your link back to the task. In practice, most spawned tasks look like this:

tokio::spawn(async move {
    run_worker().await;
});

Handle discarded. Task detached. No owner, no supervisor. That’s where things go wrong.

Silent panics. Tokio catches the panic at the task boundary and ends the task quietly. Your process keeps running. Nothing is logged unless you built a panic hook yourself. The worker is just gone.

Lost errors. Even a clean Result<_, E> return goes nowhere if nobody polls the handle. You wrote careful error-handling code. None of it mattered.

No lifecycle management. A detached task has no owner to ask “is this still running?” It runs until it finishes, panics, or the process dies. There’s no way to reason about its state from outside.

Difficult shutdown. Graceful shutdown means knowing what’s running and giving it time to stop. Detached tasks don’t live in any collection by default — so shutdown becomes “the process exits and kills whatever’s mid-flight.”

Hard monitoring. How many background tasks are running right now? Did any panic in the last hour? With bare tokio::spawn, the answer is: grep the logs and hope someone added one.

None of this is a bug. tokio::spawn is a low-level primitive — schedule a future, nothing more. The problem is using it, by default, for tasks meant to run for the service's entire lifetime.

3. What Is JoinSet?

tokio::task::JoinSet<T> owns a group of spawned tasks and lets you manage them together.

let mut tasks = tokio::task::JoinSet::new();
tasks.spawn(async move {
    run_worker().await
});

The difference: .join_next().await resolves as soon as any task finishes — success, error, or panic — and tells you which. You're not hoping a task behaves. You're watching a group of them from one place.

This is structured concurrency: tasks are owned, scoped, accounted for. JoinSet doesn't stop panics. It stops them from disappearing.

4. Real-World Example

In the indexer, four background tasks run for the life of the process:

  • Event collectors — long-lived loops pulling new events, pushing them downstream.
  • A Redis actor — owns the Redis connection, processes commands over a channel.
  • A health server — reports liveness to the deployment platform.
  • Cron jobs — periodic cleanup, reconciliation, sync.

None of these is “spawn and check later.” They’re closer to core subsystems.

That’s exactly why they can’t fail silently. An event collector panics unnoticed, and the indexer keeps running, the health check keeps passing, and the system quietly stops doing its one job. The Redis actor dies, and every dependent part fails in confusing, indirect ways. The health server dies, and you lose your only signal that something’s wrong — right when you need it.

I don’t want these tasks spawned and forgotten. I want a supervisor turning “a collector panicked” into a loud, logged, actionable event — not a slow, invisible failure.

5. Benefits of JoinSet

Structured concurrency. All long-running tasks live in one JoinSet, scoped to the run loop. One place knows what's running.

Panic detection. .join_next() returns a JoinError that flags a panic distinctly from a normal error. You catch it immediately, not later, from missing behavior.

Error propagation. Results flow back as Result<Result<T, E>, JoinError> — join-level failures and task-level failures, both visible, neither swallowed.

Graceful shutdown. The JoinSet owns every handle, so shutdown means iterating it, aborting or awaiting each task, and confirming they're gone — not trusting they wound down on their own.

Easier observability. .len() gives you a real number of tasks in flight. Paired with logging in the supervision loop, that replaces "check the logs and hope" with an actual answer.

A trimmed version from the indexer’s startup:

use tokio::task::JoinSet;

let mut tasks: JoinSet<anyhow::Result<()>> = JoinSet::new();
tasks.spawn(run_event_collector(config.clone()));
tasks.spawn(run_redis_actor(redis_rx));
tasks.spawn(run_health_server(health_config));
tasks.spawn(run_cron_scheduler());
while let Some(result) = tasks.join_next().await {
    match result {
        Ok(Ok(())) => tracing::warn!("a background task exited unexpectedly"),
        Ok(Err(err)) => tracing::error!(%err, "background task failed"),
        Err(e) if e.is_panic() => tracing::error!("background task panicked: {e}"),
        Err(e) => tracing::error!("background task was cancelled: {e}"),
    }
    break; // any exit here is critical - shut down
}
tasks.shutdown().await;

One loop. One place that knows the state of every long-running task. An explicit decision for when one stops. Nothing exits quietly.

6. JoinSet vs tokio::spawn

Not competitors. Different jobs. The mistake is using tokio::spawn for anything that needs supervision, just because it's the first API you learn.

tokio::spawn JoinSet Ownership None by default Owned by the set Panic visibility Only if you poll the handle Always, via .join_next() Grouping Manual Built in Shutdown Manual bookkeeping .shutdown() aborts everything Best for Short-lived, fire-and-forget work Long-running, lifecycle-critical work

tokio::spawn still earns its place for genuinely independent, throwaway work — a one-off webhook, a cache warm-up, a quick task where failure changes nothing and nobody's watching the result. Wrapping that in a JoinSet is overhead with no payoff.

The rule I use: if a task should run for the life of the service, or its failure should change the service’s behavior, it goes in a JoinSet. If it's a one-shot side effect nobody's holding their breath over, tokio::spawn and move on.

7. Lessons Learned

Never spawn a long-running task without a supervisor. A discarded JoinHandle is a promise your program makes to itself and doesn't keep.

Treat panics as data. is_panic() costs one line and tells you something a log alone won't: this broke in a way the code didn't expect.

Design shutdown before you need it. Retrofitting graceful shutdown onto detached tasks is miserable. Decide up front what’s critical, put it in a JoinSet, and shutdown becomes walking that set.

Not everything needs structure. Don’t wrap every spawn out of habit. Reserve JoinSet for tasks whose failure the system actually needs to know about.

Supervision is a decision, not a feature. JoinSet gives you the primitive. The engineering is deciding what's critical, and making that visible in code instead of implied by convention.

Detached tasks are easy to write and easy to forget. In a service that needs to stay reliable, “forgettable” is the last thing your background work should be.