DEV Community

mohammad garmabi
mohammad garmabi

Posted on

Refresh Tokens Without the Spaghetti: Before Expiry + After 401

How I implemented proactive + reactive token refresh with single-flight in tanstack-fetch 1.3.0

If you've ever implemented access-token refresh, you've probably seen this:

Request A → 401 → refresh
Request B → 401 → refresh
Request C → 401 → refresh
Request D → 401 → refresh
Enter fullscreen mode Exit fullscreen mode

Four requests.

Four refresh calls.

One access token.

This is one of those problems that looks easy until multiple requests fail at the same time.

With tanstack-fetch 1.3.0, I added createRefreshTokenInterceptor to handle both sides of token refresh:

  • Proactive: refresh shortly before the access token expires.
  • Reactive: refresh after the first 401.
  • Single-flight: concurrent requests share the same refresh operation.

Let's look at how it works.

What is tanstack-fetch?

tanstack-fetch is a typed Fetch client designed to work naturally with TanStack Query.

Its core contract is deliberately simple:

HTTP 2xx → return data
HTTP error → throw FetchError
request cancelled → respect AbortSignal
Enter fullscreen mode Exit fullscreen mode

So a query can stay as simple as:

useQuery({
  queryKey: ['users'],
  queryFn: ({ signal }) =>
    api.get('/users', { signal }),
})
Enter fullscreen mode Exit fullscreen mode

There is no .data wrapper and no Axios adapter layer.

The HTTP core is around 3.5 KB gzipped, while SSR, SSE, uploads, React helpers, and tRPC support are available through optional entry points.

tanstack-fetch is an independent package, not an official TanStack project.

The two refresh strategies

There are two common ways to deal with an expiring access token.

1. Refresh before expiry

If we know when the token expires, we can refresh it before sending the request.

For example:

before: {
  getExpiresAt: () => expiresAt,
  skewMs: 60_000,
}
Enter fullscreen mode Exit fullscreen mode

This means:

expiresAt - 60 seconds
          ↓
       refresh
          ↓
     send request
Enter fullscreen mode Exit fullscreen mode

The request gets the new token instead of sending an almost-expired one.

2. Refresh after a 401

Expiration timestamps aren't always perfect.

The server may invalidate a token early, clocks may differ, or the token might have expired between checking it and sending the request.

That's why the interceptor also supports reactive refresh:

Request
   ↓
  401
   ↓
 refresh
   ↓
retry request
Enter fullscreen mode Exit fullscreen mode

The retry happens only for the first unauthorized attempt.

The important part: single-flight refresh

This is where things usually get messy.

Imagine 10 TanStack Query requests are running at the same time.

The token expires.

All 10 requests receive 401.

A naive implementation can produce:

10 requests
    ↓
10 × /auth/refresh
Enter fullscreen mode Exit fullscreen mode

That's unnecessary and can cause even worse problems if the refresh token is rotated.

Instead, createRefreshTokenInterceptor makes refresh single-flight:

Request 1 ─┐
Request 2 ─┤
Request 3 ─┤
Request 4 ─┼──→ refresh()
Request 5 ─┤       │
Request 6 ─┤       ↓
Request 7 ─┤   new access token
Request 8 ─┘       │
                   ↓
              retry requests
Enter fullscreen mode Exit fullscreen mode

Multiple requests can wait for the same refresh operation.

Only one refresh request is made.

The setup

Here's the complete example:

import { createFetch } from 'tanstack-fetch'
import { createRefreshTokenInterceptor } from 'tanstack-fetch/plugins'

let accessToken = localStorage.getItem('access_token')

let expiresAt = Number(
  localStorage.getItem('access_expires_at') ?? 0,
)

const persist = (
  token: string,
  expiresInSeconds: number,
) => {
  accessToken = token

  expiresAt =
    Date.now() + expiresInSeconds * 1000

  localStorage.setItem(
    'access_token',
    accessToken,
  )

  localStorage.setItem(
    'access_expires_at',
    String(expiresAt),
  )
}

export const api = createFetch({
  baseUrl: import.meta.env.VITE_API_URL,

  getToken: () => accessToken,

  onUnauthorized: () => {
    localStorage.removeItem('access_token')
    localStorage.removeItem('access_expires_at')

    window.location.href = '/login'
  },
})

api.use(
  'refresh-token',
  createRefreshTokenInterceptor({
    refresh: async () => {
      const response = await fetch(
        '/auth/refresh',
        {
          method: 'POST',
          credentials: 'include',
        },
      )

      if (!response.ok) {
        throw new Error('refresh failed')
      }

      const body = (await response.json()) as {
        accessToken: string
        expiresIn: number
      }

      persist(
        body.accessToken,
        body.expiresIn,
      )
    },

    // Proactive refresh
    before: {
      getExpiresAt: () => expiresAt,
      skewMs: 60_000,
    },

    // Reactive refresh
    after: {
      enabled: true,
    },
  }),
)
Enter fullscreen mode Exit fullscreen mode

That's basically it.

How the interceptor behaves

The two options have different responsibilities:

Strategy Trigger Action
before Token is inside the expiry window Refresh before request
after First 401 Refresh and retry
single-flight Multiple refreshes happen concurrently Share one refresh operation

This also means you can choose only the behavior you need.

Only reactive refresh

createRefreshTokenInterceptor({
  refresh,

  after: {
    enabled: true,
  },
})
Enter fullscreen mode Exit fullscreen mode

Only proactive refresh

createRefreshTokenInterceptor({
  refresh,

  before: {
    getExpiresAt: () => expiresAt,
    skewMs: 60_000,
  },

  after: false,
})
Enter fullscreen mode Exit fullscreen mode

Both

createRefreshTokenInterceptor({
  refresh,

  before: {
    getExpiresAt: () => expiresAt,
    skewMs: 60_000,
  },

  after: {
    enabled: true,
  },
})
Enter fullscreen mode Exit fullscreen mode

For most applications, having both gives you a useful fallback:

             token nearly expired?
                    │
             ┌──────┴──────┐
             │             │
            yes            no
             │             │
          refresh       send request
             │             │
             └──────┬──────┘
                    ↓
                  API
                    │
                  401?
                    │
             ┌──────┴──────┐
             │             │
             no            yes
             │             │
            done        refresh once
                           │
                           ↓
                         retry
Enter fullscreen mode Exit fullscreen mode

What if refresh fails?

The refresh interceptor doesn't need to own your logout logic.

If refreshing fails, the request can continue through your normal unauthorized handling.

For example:

onUnauthorized: () => {
  localStorage.removeItem('access_token')
  localStorage.removeItem('access_expires_at')

  window.location.href = '/login'
}
Enter fullscreen mode Exit fullscreen mode

This keeps responsibilities separate:

Refresh interceptor

Can this request be recovered with a new token?

Auth layer

Can this user continue their session?

TanStack Query

What should happen to the query state?

I prefer this separation because authentication recovery doesn't end up duplicated across every query or page.

Why not put this inside useQuery?

You could do something like this:

useQuery({
  queryKey: ['users'],

  queryFn: async () => {
    try {
      return await api.get('/users')
    } catch (error) {
      // refresh?
      // retry?
      // logout?
    }
  },
})
Enter fullscreen mode Exit fullscreen mode

But then you'll eventually repeat the same logic across multiple queries.

The HTTP layer is a better place for HTTP authentication concerns.

Your query remains:

useQuery({
  queryKey: ['users'],
  queryFn: ({ signal }) =>
    api.get('/users', { signal }),
})
Enter fullscreen mode Exit fullscreen mode

And authentication recovery happens below it.

Other recent improvements

Path-typed parameters — 1.2.1

Path parameters can be inferred directly:

api.get('/users/:id', {
  params: {
    id: userId,
  },
})
Enter fullscreen mode Exit fullscreen mode

Required parameters are inferred from :param and {param} patterns.

First-class 4xx handlers — 1.2.0

The client also includes handlers for common HTTP cases:

onUnauthorized
onTooManyRequests
onClientError
parseRetryAfter
Enter fullscreen mode Exit fullscreen mode

This keeps HTTP error behavior centralized instead of spreading status-code checks throughout your application.

Documentation

I've also been improving the documentation around the project.

It now includes:

  • a clearer homepage
  • live comparison examples
  • StackBlitz examples
  • llms.txt
  • a dedicated refresh-token recipe

Install

npm install tanstack-fetch @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

Then:

import { createFetch } from 'tanstack-fetch'
import { useQuery } from '@tanstack/react-query'

const api = createFetch({
  baseUrl: 'https://api.example.com',
})

useQuery({
  queryKey: ['users'],
  queryFn: ({ signal }) =>
    api.get('/users', { signal }),
})
Enter fullscreen mode Exit fullscreen mode

Links

📦 npm

https://www.npmjs.com/package/tanstack-fetch

📚 Documentation

https://mohamadgarmabi.github.io/tanstack-fetch/

🔐 Refresh Token Recipe

https://mohamadgarmabi.github.io/tanstack-fetch/recipes/refresh-token

💻 GitHub

https://github.com/mohamadgarmabi/tanstack-fetch

📝 Changelog — 1.3.0

https://github.com/mohamadgarmabi/tanstack-fetch/blob/main/CHANGELOG.md

Final thought

Token refresh is easy when you have one request.

It's much more interesting when 10 requests fail simultaneously.

The goal of createRefreshTokenInterceptor is to make that case boring:

  • Refresh before expiry when possible.
  • Recover after a 401 when necessary.
  • Share concurrent refresh operations.
  • Retry the original request once.
  • Let the application decide what happens when recovery fails.

If you're using TanStack Query and you've implemented your own refresh queue, I'd be interested in how you handled it.

What edge cases should createRefreshTokenInterceptor handle next?

Issues and PRs are welcome.

Top comments (0)