Cancelling In-Flight Queries With AbortController

TanStack Query creates an AbortController for every fetch and hands you its signal. If that signal never reaches the network call, cancellation compiles, runs, reports success and does nothing — the superseded request completes in full, occupies a connection, and is then discarded. That silent no-op is the default state of most codebases, because nothing warns you about it. This page covers wiring it properly, under Concurrency & Race Conditions. It is the request-level half of the story; the response-level half, for paths where cancellation is not available, is Resolving Out-of-Order Responses With Request IDs, and the mutation-ordering half is Serializing Concurrent Mutations to One Entity.

Diagnostic Checklist

Prerequisites

  • TanStack Query v5, where signal is always present on the query function context.
  • Network throttling available in DevTools — cancellation is invisible on a fast connection.
  • An HTTP client whose cancellation option you can identify.
Where the abort signal has to reach The query observer creates an AbortController per fetch and passes its signal into the query function. The signal must be forwarded into the HTTP client and then into the underlying fetch call. If it stops at any point, the abort is a no-op and the request runs to completion. The signal has to travel all the way down, or nothing is cancelled observer creates a controller queryFn ({ signal }) => HTTP client signal · cancelToken socket the usual break: signal destructured but never forwarded abort() resolves, the request runs to completion, the result is discarded The cache is correct either way — a superseded response is discarded regardless What cancellation buys is the connection, the server capacity and the user's bytes.
Cancellation is not a correctness fix — the cache already discards stale responses. It is a resource fix, and it fails silently.

Step 1 — Forward the Signal Into the HTTP Client

Every client exposes cancellation differently. The one rule is that the signal must be a parameter all the way down.

// fetch — the signal goes straight in.
export const fetchTransport = {
  searchTodos: (term: string, signal?: AbortSignal) =>
    fetch(`/api/todos?q=${encodeURIComponent(term)}`, { signal }).then((r) => {
      if (!r.ok) throw new ApiError(`Search failed: ${r.status}`, r.status, null, r.status >= 500);
      return r.json() as Promise<Todo[]>;
    }),
};

// axios — modern versions accept a native AbortSignal directly.
import axios from 'axios';
export const axiosTransport = {
  searchTodos: (term: string, signal?: AbortSignal) =>
    axios.get<Todo[]>('/api/todos', { params: { q: term }, signal }).then((r) => r.data),
};

// graphql-request — the signal belongs on the request init, not the variables.
import { GraphQLClient } from 'graphql-request';
const gql = new GraphQLClient('/graphql');
export const graphqlTransport = {
  searchTodos: (term: string, signal?: AbortSignal) =>
    gql.request<{ todos: Todo[] }>({ document: SEARCH_TODOS, variables: { term }, signal })
      .then((data) => data.todos),
};

Cache Behavior Analysis: TanStack Query aborts the controller when a new fetch supersedes the current one, when cancelQueries is called, or when the last observer unsubscribes — all three go through the same signal, so wiring it once covers every case. Apollo Client is the exception worth knowing: it manages its own cancellation through the observable returned by watchQuery, so a query wrapped in TanStack Query needs the signal forwarded into the link chain via context.fetchOptions.signal. Older axios versions used CancelToken, which is deprecated; if you are on one, the migration is worth doing precisely because the native signal composes with everything else.


Step 2 — Let AbortError Propagate

An aborted request must not be converted into an error state. Catching it and returning a fallback is how a cancelled fetch becomes a rendered error.

export function useTodoSearch(term: string) {
  return useQuery({
    queryKey: ['todos', 'search', term],
    queryFn: async ({ signal }) => {
      try {
        return await fetchTransport.searchTodos(term, signal);
      } catch (error) {
        // Re-throw AbortError untouched: the library recognises it and treats
        // the fetch as cancelled rather than failed.
        if (error instanceof DOMException && error.name === 'AbortError') throw error;
        // Everything else is a real failure worth reporting.
        throw normalizeError(error);
      }
    },
    enabled: term.length > 1,
    placeholderData: (previous) => previous,
  });
}

Cache Behavior Analysis: When a fetch is aborted, TanStack Query rolls the query’s fetchStatus back to idle and leaves status and the cached data untouched, so a cancelled request produces no error state and no re-render into a failure branch. Swallowing the AbortError and returning [] instead would write an empty array into the cache as a successful result — the classic cause of “the list briefly goes empty while typing”. Note that retry logic also respects cancellation: an aborted attempt does not count toward the retry budget, so a fast typist does not exhaust retries on a query that never actually failed.

Concurrent in-flight requests while typing a 14-character term Line chart across fourteen keystrokes. Without the signal forwarded, in-flight requests climb to six and plateau at the browser connection limit. With the signal forwarded, the count stays at one. Concurrent in-flight requests while typing a 14-character term signal not forwarded signal forwarded 0 1.5 3 4.5 6 in flight 1 3 5 7 9 11 14 keystroke
The plateau at six is the browser's per-origin connection limit, not a coincidence — the search box is saturating the pool by itself.

Step 3 — Cancel Queries Before an Optimistic Write

cancelQueries is the reason optimistic updates are stable. Without it, a refetch already in flight can land on top of the optimistic value with pre-mutation data.

export function useRenameTodo(todoId: string) {
  const client = useQueryClient();

  return useMutation({
    mutationFn: (title: string) => fetchTransport.renameTodo(todoId, title),

    onMutate: async (title) => {
      // Stop any in-flight refetch for these keys. Without this, a refetch
      // that started 100ms ago can resolve after our write and undo it.
      await client.cancelQueries({ queryKey: ['todos', 'detail', todoId] });
      await client.cancelQueries({ queryKey: ['todos', 'list'] });

      const previousDetail = client.getQueryData<Todo>(['todos', 'detail', todoId]);
      client.setQueryData<Todo>(['todos', 'detail', todoId], (current) =>
        current ? { ...current, title } : current,
      );
      return { previousDetail };
    },

    onError: (_error, _title, context) => {
      if (context?.previousDetail) {
        client.setQueryData(['todos', 'detail', todoId], context.previousDetail);
      }
    },

    onSettled: () => client.invalidateQueries({ queryKey: ['todos'] }),
  });
}

Cache Behavior Analysis: cancelQueries aborts in-flight fetches for the matching keys and returns their fetchStatus to idle, but deliberately leaves the cached data and every observer intact — it is a scheduling operation, not a data operation, which is what makes it safe to call at the top of onMutate. Awaiting it matters: the abort propagates through the promise chain, and writing the optimistic value before the cancellation has settled leaves the same race you were removing. Note that it does not prevent a fetch that starts afterwards; that is what the invalidation in onSettled is for, and it is why the two calls are at opposite ends of the mutation lifecycle.


Step 4 — Verify Under Throttling

Cancellation that does not work looks identical to cancellation that does, unless requests are slow enough to overlap.

// A test that fails when the signal is not forwarded.
test('superseded search requests are aborted', async () => {
  const aborted: string[] = [];
  const server = setupServer(
    http.get('/api/todos', async ({ request }) => {
      request.signal.addEventListener('abort', () => aborted.push(request.url));
      await delay(300);
      return HttpResponse.json([]);
    }),
  );

  const { rerender } = renderHook(({ term }) => useTodoSearch(term), {
    initialProps: { term: 're' },
    wrapper,
  });

  await delay(20);
  rerender({ term: 'rea' });
  await delay(20);
  rerender({ term: 'reac' });
  await waitFor(() => expect(aborted).toHaveLength(2));
});

Cache Behavior Analysis: Asserting on the server’s view of the abort — rather than on the client’s state — is what makes this test meaningful, because the client behaves identically whether or not the request was actually stopped. The 300-millisecond delay is doing the same job as DevTools throttling: without overlap there is nothing to cancel and the test passes vacuously. In a browser, the equivalent check is the network panel under a throttled profile, where cancelled requests appear with a (cancelled) status; if you never see one while typing quickly, the signal is not reaching fetch.

How each client accepts a cancellation signal Matrix of five HTTP and GraphQL clients showing where the abort signal goes and what happens on abort. How each client accepts a cancellation signal Where the signal goes On abort fetch The signal option on RequestInit Rejects with a DOMException named AbortError axios 1.x The signal option on the request config Rejects with a CanceledError graphql-request 6+ The signal field on the request object Rejects with the underlying fetch AbortError Apollo Client 3 context.fetchOptions.signal on the operation The observable errors; the link chain tears down ky and wretch The signal option, forwarded to fetch Rejects with AbortError
Four of these five take a native AbortSignal. Apollo is the one that needs the signal pushed into its link context.

Edge Cases & Gotchas

Aborting a write is not the same as undoing it. A POST aborted after the server received it may still have committed. Aborting reads is free; aborting writes leaves your client unable to say whether the change happened, which is worse than waiting.

Retries create new signals. Each retry attempt gets its own controller, so a signal captured outside the query function refers only to the first attempt. Always read signal from the context argument on every invocation.

Streaming responses need explicit teardown. An aborted fetch rejects the promise but a reader already consuming response.body must be released too, or the connection stays open. Listen for signal.aborted inside the read loop.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
No requests ever show as cancelled The signal is destructured but not forwarded Trace the signal into the actual fetch call; assert on it in a test
The list flashes empty while typing AbortError was caught and an empty array returned Re-throw AbortError untouched
An optimistic update reverts and then reapplies A refetch in flight landed after the optimistic write await cancelQueries at the top of onMutate
Retries are exhausted by fast typing Retry logic counts aborted attempts Let the library see the AbortError; it excludes them itself
Cancellation works in dev, not in a test Requests resolve instantly, so nothing overlaps Add a delay to the mock handler

Frequently Asked Questions

What actually cancels a query in TanStack Query v5?

Three things, all routed through the same controller: a new fetch for the same query key supersedes the current one, an explicit cancelQueries call matching the key, or the last observer unsubscribing while a fetch is running. In each case the library calls abort() on the controller whose signal it handed to queryFn. If that signal was never forwarded into the network call, all three still “work” from the library’s point of view — the promise is abandoned and the response discarded — while the request itself runs to completion on the wire.

Should mutations be cancellable too?

Rarely, and never by default. Aborting a read costs nothing: the data is still on the server and you can ask again. Aborting a write leaves you in the worst possible state — unable to tell whether the server committed the change, so the client’s model is not stale but unknown, and the only recovery is a refetch that may race with a delayed commit. The exception is an endpoint that is explicitly idempotent and whose partial application is harmless, and even there it is usually better to let the request finish and reconcile.

Why is cancelQueries the first line of onMutate?

Because a background refetch that started before the mutation can resolve after your optimistic write and overwrite it with pre-mutation server data — the user sees their edit appear and then vanish, before reappearing when the mutation settles. cancelQueries removes that window by aborting anything in flight for the affected keys, and it is safe to call there because it touches scheduling only: cached data, observers and subscriptions all survive untouched. Awaiting it is part of the fix; writing before the cancellation settles leaves the race in place.