- Published on
Don't detach a task just to hide a slow request
- Authors

- Name
- Mehdi Akiki
Article · Interrupted execution
An export takes too long for the HTTP request. Someone wraps it in a spawned task, returns immediately, and calls the endpoint fixed.
The response got faster. Several questions moved out of view. Who notices a failure? Can the user cancel it? What happens during deployment? Will a repeated request start another export?
I would answer those questions before choosing the task primitive. Background execution is a lifecycle decision, not just a way to make a handler stop waiting.
Dropping the handle does not stop the task
Tokio documents that dropping a JoinHandle detaches the task. It can keep running, and its returned result is no longer available through that handle.
If a request disconnects while the handler owns a JoinHandle, dropping the handler can therefore leave work behind. That may be exactly what you want. It should be a deliberate promise.
For request-owned tasks, keep an owner that asks for cancellation when it goes away. This small Rust wrapper illustrates that policy:
struct RequestTask<T>(tokio::task::JoinHandle<T>);
impl<T> RequestTask<T> {
async fn join(&mut self) -> Result<T, tokio::task::JoinError> {
(&mut self.0).await
}
}
impl<T> Drop for RequestTask<T> {
fn drop(&mut self) {
self.0.abort();
}
}
This is not a durable job system. Aborting is a cancellation request, not proof that every effect was undone. It also does not reliably stop work already running in a blocking task. A real owner should observe task completion where its lifecycle allows it.
Call join on the normal completion path and handle its result. It borrows the handle because Rust does not allow moving a field out of a type with Drop. After cancellation, an async shutdown owner can wait for completion. Drop itself cannot await, so this wrapper requests cancellation without claiming that cleanup has finished.
The cancellation-safety article explains why dropping a future is only safe when its operations tolerate that interruption. A task may have committed a database write before its next await.
Persistent exports need persistent state
If the export should continue after the user closes the tab or the service restarts, store a job before returning an acceptance response. Give it a stable identity, owner, input version, state, and result location.
A worker can claim it with a lease and record progress. The lease needs recovery after a crash. The work needs a duplicate policy, especially when it calls external services or writes shared records.
Expose a status route and decide how cancellation works. “Cancellation requested” and “cancelled with no further work” may be different states. A user should not be told that a completed external action was reversed merely because the local task was stopped.
Test the missing owner
Start a controlled export and disconnect the request while it waits for a signal. Check whether work stops or continues according to the chosen contract. If it continues, verify that somebody records its result or failure.
Then stop the service with accepted work in progress. A durable design should recover it from the store. A request-owned design should not pretend that recovery is guaranteed.
Some short telemetry or best-effort work may be acceptable to lose. Keep its volume bounded and make that loss policy explicit. It should not be the accidental architecture for customer exports.
The code review question is simple: after this handler disappears, who owns the work? If the answer is nobody, spawning it has probably hidden the problem.
Cancellation after a committed write
The runtime fixture adds a service boundary to the small wrapper tests. A task writes an operation row to PostgreSQL, reports that the statement committed, and then waits indefinitely. Only after receiving that signal does the observer drop the request owner.
A drop marker inside the task reports when its future is destroyed. The observer waits for that marker under an outer deadline. It then reads the operation through another query. The committed row must still exist. Cancellation ended the waiting task; it did not erase an effect completed before that wait.
The separate article-derived tests exercise normal completion, panic, and cancellation. Normal completion uses the borrowed join method. A panic must remain visible through JoinError. Cancellation must eventually destroy the controlled future. These are stronger facts than merely dropping a handle and assuming its work disappeared.
The database case is an autocommitted local write. It is not a process-crash test, a remote cleanup demonstration, or proof that abort can interrupt a blocking call. The fixture deliberately waits at a cancellable async point after the write. Moving that boundary changes what the observer can establish.
A durable export may legitimately continue after the request disappears. Give that work a stored owner and outcome, then connect it to the job lease fixture. The small cancellation wrapper is useful when request ownership is the intended contract. Its value comes from making that ownership explicit, with a precise limit on what cancellation means.