skip to content

questions

4

A developer starts a background task by spawning it and immediately returning, keeping no handle to it. What can go wrong with such fire-and-forget tasks?

level: juniorimportance: must knowfreq 58%

answer

  1. no handle = no owner
  2. errors land in the void
  3. outlives its inputs and its resources
  4. cannot cancel, cannot count
  5. shutdown kills or hangs on it

basics

~20 s

Nobody owns it. Failures vanish silently, nobody waits for it, it can outlive the data and resources it borrowed, it cannot be cancelled or given a deadline, and shutdown can kill it mid-work. The leak stays invisible.

solid answer

~50 s

Fire-and-forget means the spawning code keeps no reference, so **no one owns the task's outcome**. Consequences: - **Errors disappear.** The failure has nowhere to propagate; at best it reaches a default handler, at worst it is swallowed, so the caller reports success for work that never happened. - **Lifetime is unbounded.** The task may still run after the request ends, after the buffers, connections or context it borrowed are recycled or closed, and at process shutdown it is either killed mid-write or blocks exit forever. - **No control.** You cannot cancel it, apply a timeout, or even count how many are alive, so if the spawn rate exceeds the completion rate the count grows without backpressure. - **No observability.** Nothing ties the task back to the request that caused it. Structured concurrency's answer: every task belongs to a scope that waits for it, so a child's lifetime nests inside its parent's.

go deeper

for a junior

Be able to say plainly: nobody waits for it, nobody sees its errors, and it can still be running after the request or the process is done.

for a middle

Add the resource angle — pooled buffers and connections get recycled under the orphan — and the lack of a cancellation or timeout handle.

for a senior

Frame it as unbounded concurrency with no backpressure and no observability, and describe converting spawns into scoped children owned by a request scope or an application scope.

for a principal

Argue the policy: orphan tasks are forbidden by default, background daemons live in explicitly named application-lifetime scopes with shutdown ordering, and the platform should make the unstructured spawn hard to reach at all.

## What fire-and-forget means A task is *fire-and-forget* when the code that starts it keeps no handle: no future to await, no join reference, no registration with a supervisor. The spawn call returns immediately and the task runs somewhere on a shared runtime. There is no way to ask "did it finish?", "did it fail?", or "please stop". In structured-concurrency vocabulary it is an **orphan**: a task with no parent responsible for it. ## Failure has nowhere to go In sequential code an error propagates up the call stack until someone handles it; the stack *is* the chain of ownership. A spawned task has no caller on its stack to unwind into. Its error therefore reaches, at best, a global "unhandled task exception" hook, and at worst nothing at all. The visible symptom is a system that reports success while some fraction of the real work silently never happened — emails not sent, cache entries not written, audit rows missing. These bugs surface days later as data inconsistency, not as an alert. ## Nobody waits, so lifetimes are unbounded The spawning function returns while the task is still running. Three failure modes follow. 1. **Outliving the request.** The task keeps working after the response is sent. If it touches request-scoped state — a context object, a security principal, a per-request buffer, an open transaction — that state may already be closed, cleared, or handed to another request. 2. **Outliving a resource.** A pooled connection or byte buffer returned to its pool can be handed to someone else while the orphan still writes to it. This is the "use-after-close" family of bugs: data corruption and cross-request data leaks, not just crashes. 3. **Surviving into shutdown.** At exit either the runtime kills the task mid-operation (half-written file, partially applied update) or waits forever on a task nobody can cancel. Both are common production incidents. ## No control surface Because no handle exists you cannot cancel the task, impose a timeout on it, or wait for a quiet point. You also cannot *count* it. That matters most under load: if each request spawns work and the spawn rate exceeds the completion rate, the number of live tasks grows without limit — memory, runtime queues, and thread or connection pools fill up with work nobody is tracking. There is no natural backpressure, because backpressure requires somebody to be waiting. ## The structured alternative Structured concurrency inverts the default. Tasks are started inside a **scope** (also called a nursery or task group), and the scope does not exit until every task inside it has finished. That single rule restores the properties sequential code always had: failures propagate to the code that started the work, lifetimes nest so a child can never outlive the resources its parent holds, one place can cancel the whole subtree, and the number of live tasks is bounded by the scopes currently open. ```text unstructured: spawn(work) -> returns now, task lives on, owner unknown structured: with scope: -> block ends only after work() finished scope.start(work) ``` ## Is fire-and-forget ever fine? Some work genuinely must outlive a request — a metrics flusher, a cache refresher, a keep-alive loop. The structured answer is not "never spawn" but *"every task has an owning scope; the question is which one"*. Such tasks belong to a longer-lived scope (an application- or component-lifetime scope) that still joins and cancels them at shutdown. What is forbidden is a task owned by *nobody*. And even for owned background work, if the caller does not need the result, someone must still capture and report its errors — otherwise you have re-created the silent-failure problem inside a scope.

  • Is it enough to wrap the background task body in a try/catch that logs the error?
    It fixes only the silent-failure symptom, and only if someone reads the log. The caller still gets no signal, so it cannot retry, degrade, or report partial failure to the user. And logging does nothing about the other three problems: the task still cannot be cancelled or timed out, it can still outlive borrowed resources, and it is still uncounted under load.
  • When is spawning a task without awaiting it acceptable?
    When the work legitimately has a longer lifetime than the current call — background refreshers, schedulers, keep-alives — and it is attached to a longer-lived scope that joins and cancels it at shutdown. The distinction is ownership, not awaiting: the immediate caller need not wait, but some scope must still be responsible for cancellation, error reporting, and final join.

It is like sending a courier out with no receipt, no phone number, and no delivery deadline. You never learn whether the parcel arrived, you cannot recall them, and when you close the office they are still out there holding your keys.

saying these in an interview costs you the question

  • "It's fine, the runtime will clean it up when the process exits" — exit is exactly when the task gets killed mid-write.
  • Believing an error inside a spawned task will surface to the code that spawned it.
  • Treating unbounded spawning as free because tasks are cheap — the cost is unbounded concurrency and no backpressure, not per-task memory.
  • Assuming request-scoped context and pooled resources stay valid after the request returns.
  • Confusing "the caller does not wait for the result" with "nothing needs to own the task".

context

open as a page

In structured concurrency, a scope (also called a nursery or task group) cannot exit until every task started inside it has finished. What guarantees does that rule buy, and what does it cost?

level: middleimportance: must knowfreq 52%

basics

~20 s

It makes concurrency nest like a block of code: on exit no task from that block is still running. So lifetimes are bounded, errors have somewhere to propagate, and cancellation has a subtree. Cost: the slowest child gates the block, so you must add timeouts and cancellation.

open as a page

Why is a concurrent task that outlives the code region that started it dangerous when that region owns resources such as a pooled connection, a reusable buffer, a request-scoped context, or an open transaction?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Because release is tied to the region, not the task. When the region ends it closes or recycles the resource, and the surviving task keeps using it — writing into a buffer or connection now owned by someone else. That is corruption and cross-request data leakage, not just a crash.

open as a page

Some concurrent work legitimately outlives any single request — background schedulers, cache refreshers, connection keep-alive loops, metric flushers. How do you reconcile that with a discipline that forbids orphan tasks?

level: principalimportance: should knowfreq 28%

basics

~20 s

Give them a longer-lived owner instead of no owner. Each background task belongs to an explicit component or application-lifetime scope that starts it, watches its failures, and cancels and joins it in a defined shutdown order. Ownership moves up the tree; it never disappears.

open as a page