Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 3 min read

tanstack-fetch 2.1.0: abortable retries, safer token refresh

Originally published at https://mohamadgarmabi.github.io/tanstack-fetch/blog/tanstack-fetch-2-1-0 2.1.0 is a fix-first release. No breaking changes. Upgrade and you get: Cancel means cancel: aborting during a retry

Originally published at https://mohamadgarmabi.github.io/tanstack-fetch/blog/tanstack-fetch-2-1-0

2.1.0 is a fix-first release. No breaking changes. Upgrade and you get:

  • Cancel means cancel: aborting during a retry delay rejects immediately
  • Literal colons in paths: /models/gemini:generateContent no longer asks for a generateContent param
  • One refresh per rotation: a late 401 no longer starts a second refresh
  • Array query values, parseAs: 'arrayBuffer' | 'stream' and a tanstack-fetch/full entry

Upgrade

npm install [email protected]

Fixed

Cancelling during a retry delay

With Retry-After: 5 (or any backoff), an HTTP request waited out the whole delay even after its signal was aborted. TanStack Query aborts on unmount and on queryKey change, so the query stayed pending for seconds. The delay now listens to your signal and rejects with AbortError immediately, without sending the next attempt.

useQuery({
  queryKey: ['report', id],
  // unmount during a 5s Retry-After โ†’ rejects now, no second fetch
  queryFn: ({ signal }) => api.get('/reports/:id', { params: { id }, signal }),
})

Colons inside a path segment

:name is now a path param only at the start of a segment, both at runtime and in the types:

await api.post('/v1/models/gemini:generateContent', { body }) // literal path, no params
await api.get('/users/:id', { params: { id: 1 } }) // โ†’ /users/1

Before, the first call threw missing path param "generateContent" and TypeScript asked for that param too.

Refresh token: no second refresh on a late 401

The interceptor already shared one in-flight refresh between parallel 401s. But a request sent with the old token whose 401 arrived after the refresh had finished started a new refresh. With rotating refresh tokens, that second call can invalidate the session the first one just created.

The interceptor now knows which refresh each request was sent after. A late 401 just resends the request with the new token:

A โ”€โ”€(old token)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ 401 โ†’ retry with new token โœ“
B โ”€โ”€(old token)โ”€โ”€โ–บ 401 โ†’ refresh() โœ“ โ†’ retry โœ“
                       refresh() called once

It also fixes the reverse case. A request that was retried for another reason (say a 502 through retry-idempotent) and then got a 401 used to skip the refresh, because the check was attempt > 0. Each request now refreshes at most once, tracked per call, so 401 โ†’ refresh โ†’ 401 still can't loop.

This works through a new context.meta.state, a plain object shared by every retry attempt of one request. Your own interceptors can use it for per-call flags too.

Stream bodies are never retried

A ReadableStream body is consumed by the first attempt, so a retry used to fail with a confusing "locked stream" TypeError. Requests with a stream body now return the real error (FetchError with the 503, etc.) instead of retrying.

Added

Array query values

await api.get('/users', { query: { ids: [1, 2], role: 'admin' } })
// โ†’ /users?ids=1&ids=2&role=admin

QueryParams accepts arrays of string | number | boolean, and each item is appended as its own key=value. null and undefined are still skipped.

parseAs: 'arrayBuffer' | 'stream'

const bytes = await api.get<ArrayBuffer>('/files/:id', { params: { id }, parseAs: 'arrayBuffer' })
const body = await api.get<ReadableStream<Uint8Array>>('/export', { parseAs: 'stream' })

'stream' returns the raw response.body without reading it.

tanstack-fetch/full

The full HTTP client (option shortcuts, upload(), progress events) lived only under tanstack-fetch/sse, which reads oddly when you don't use SSE. tanstack-fetch/full is the same build under a clearer name, and /sse keeps working.

import { createFetch } from 'tanstack-fetch/full'

Correct types for CommonJS

require('tanstack-fetch') now resolves to .d.cts declarations instead of the ESM .d.ts, so TypeScript projects using module: node16 / nodenext with CJS get the correct types. CI now checks every entry with publint and @arethetypeswrong/cli, and runs the tests on Node 20, 22 and 24.

Bundle size

Import 2.0.0 2.1.0
tanstack-fetch 3.57KB 3.68KB
tanstack-fetch/sse 6.33KB 6.43KB
tanstack-fetch/live 8.75KB 8.84KB
tanstack-fetch/plugins 1.13KB 1.22KB

About 100 bytes for the fixes above.

Good to know

Timeouts and aborts reject with the platform TimeoutError / AbortError, not FetchError. That has always been true, and it is now stated in the README. Check them with isAbortError(error). See Errors.

See also: Refresh token recipe ยท Request options ยท Changelog

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.