The Cancel button on a large upload does nothing
Mutations do not get the automatic abort signal queries do, so a working Cancel means threading your own controller through
What you'll build: a mutation that can be aborted from the UI — because unlike queryFn, mutationFn does not receive Query's auto signal — by threading your own AbortController through mutateAsync/mutate variables into fetch/axios.
The scenario
A creator uploads a 400MB video with useMutation. UX includes Cancel. The handler calls queryClient.cancelQueries() — wrong verb, and mutations aren't queries. Or they call mutation.reset() — clears mutation state, HTTP keeps uploading. Network panel still shows the request until it finishes; server may still process the file. Users mash Cancel; support gets duplicate half-uploads.
Why it escaped QA: small fixture files finish before Cancel is clicked; cancel "clears the progress UI" via reset() so it looks cancelled.
Walkthrough
Stage 1 — Name it: mutations don't auto-abort
Article note: queryFn gets { signal } and Query aborts on unmount/key change. mutationFn gets variables only. Writes are not cancelled when you navigate away — by design (avoid "pay button → navigate → silent abort"). UI cancel is opt-in.
Stage 2 — Reject the wrong cancels
cancelQueries— stops reads; upload continues (cancel vs invalidate).mutation.reset()— UI state only.- Ignoring the body stream — must pass
signalintofetch/xhr/axios.
Stage 3 — Own the AbortController
import { useMutation } from "@tanstack/react-query";import { useRef, useState } from "react";
type UploadVars = { file: File; signal: AbortSignal };
async function uploadVideo({ file, signal }: UploadVars) { const res = await fetch("/api/videos", { method: "POST", body: file, signal, headers: { "Content-Type": file.type }, }); if (!res.ok) throw new Error("upload failed"); return res.json() as Promise<{ id: string }>;}
export function VideoUploader() { const controllerRef = useRef<AbortController | null>(null); const [progress, setProgress] = useState(0); // wire XHR if you need real %
const upload = useMutation({ mutationFn: uploadVideo, });
function start(file: File) { controllerRef.current?.abort(); const controller = new AbortController(); controllerRef.current = controller; upload.mutate( { file, signal: controller.signal }, { onSettled: () => { if (controllerRef.current === controller) controllerRef.current = null; }, }, ); }
function cancel() { controllerRef.current?.abort(); }
return ( <div> <input type="file" accept="video/*" onChange={(e) => { const file = e.target.files?.[0]; if (file) start(file); }} /> <button type="button" disabled={!upload.isPending} onClick={cancel}> Cancel </button> {upload.isPending && <p>Uploading…</p>} {upload.isError && upload.error.name !== "AbortError" && <p>{upload.error.message}</p>} </div> );}Treat abort as non-failure in UX (no error toast). Server should tolerate aborted uploads (temp GC).
Stage 4 — Harden + verify the loop
- Don't abort payments on route change automatically — only explicit Cancel.
- Axios:
axios.post(url, data, { signal }). - For progress,
XMLHttpRequest+signalviaAbortController+xhr.abort()on abort event.
Verify the loop. Start a large upload, click Cancel: Network shows (canceled); isPending false; no success toast; server receives connection close. cancelQueries alone: upload continues (control experiment).
Variations
mutateAsync+ try/catch — catchAbortErrorseparately.- Multi-file — one controller per file or one shared abort-all.
- TanStack Router nav block — confirm before leaving mid-upload.
- Resumable uploads (TUS) — abort pauses; cancel ≠ delete server draft (product decision).
- Query downloads — use
cancelQueries+signalinqueryFninstead (reads).
Trade-offs and common pitfalls
- Expecting mutation auto-signal — doesn't exist.
reset()as cancel — cosmetic.cancelQueriesfor uploads — wrong API.- Not forwarding
signal— abort is a no-op (search-race same footgun). - Toasting AbortError as failure — scares users.
- Aborting money transfers on blur — data inconsistency risk.
- Shared controller across sequential uploads — abort kills the wrong one; per-attempt controller.
- No server-side temp cleanup — orphan blobs.
- Testing with tiny files — Cancel never races completion.
- Service worker caching the POST — rare; verify canceled in Network.
When NOT to abort mutations
Idempotent "save draft" that already committed server-side — Cancel only stops in-flight bytes; it won't undo a finished write. For payments and irreversible deletes, prefer disable-nav + pending UI over abort-as-undo.
See also
mutatevsmutateAsync- Cancel vs invalidate
- Effects AbortController pattern — same signal discipline for reads
References
- MDN —
AbortController - TanStack Query — Mutations
Demo source
demos/data-fetching/upload-cant-be-cancelled/— Cancel aborts HTTP vs reset-only. (Demo host TBD)