# How to refresh an access token once under concurrent requests

> Hold the refresh in a single promise so concurrent callers await the same request, and a burst of expiries makes one call to the token endpoint.

Source: https://voxgig.com/howto/refresh-a-token-once-under-concurrent-requests

- Audience: api-consumer
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## Short answer

Store the in-flight refresh as a promise on the token store and hand that same promise to every caller who arrives while it is pending. Clear it in a finally block so the next expiry starts a new refresh. Ten callers that all see an expired token then produce one request to the token endpoint, and all ten end up holding the same token.

---
## You will need

Node 22 or later, and a credential that expires. The flow below assumes a client credentials grant
from [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749). Nothing in the pattern depends on the
grant. A session cookie or a signed assertion with a lifetime has the same problem.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A shared promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) | One process holding one credential | Coordination stops at the process boundary, so eight workers still make eight calls | Many processes share one credential and the endpoint counts them together |
| [async-mutex](https://github.com/DirtyHairy/async-mutex) | You already lock other shared resources the same way | A dependency, and a lock you have to release on every path including the throwing ones | The only thing you serialize is the refresh |
| [Refreshing early](https://www.rfc-editor.org/rfc/rfc6749#section-4.2.2) | The token has a known lifetime and the clock is trustworthy | Clock skew between you and the issuer turns an early refresh into a late one | The issuer returns no expiry, or a very short one |
| [A refresh per caller](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch) | A script making one call at a time | Nothing at low volume, then everything at once when concurrency arrives | Two calls can ever overlap |

The shared promise and the mutex solve the same problem in one process, and neither solves it across
processes. Refreshing early reduces how often the race can happen at all, so it composes with
either. Refresh at 80% of the lifetime and the burst rarely arrives at a stale token. The shared
promise handles the times it does.

Coordinating across processes is a different problem with a different price. A shared store turns
every token read into a network call, and the lock you need around it is the one thing you were
avoiding. Most services can pay the extra token requests instead, and the ones that cannot are
usually the ones already running a shared cache for other reasons.

## Hold the refresh in one promise

The whole mechanism is the variable that outlives a single call.

```ts title="refresh.mjs"
export function makeTokenStore({ fetchToken }) {
  let token = null
  let inFlight = null

  async function refresh() {
    // The second caller to arrive gets the first caller's promise, not a second
    // request. Clearing inFlight in a finally is what makes the next expiry
    // start a new refresh rather than replaying this one forever.
    if (!inFlight) {
      inFlight = fetchToken().finally(() => { inFlight = null })
    }
    token = await inFlight
    return token
  }

  return {
    async get() {
      if (token && token.expiresAt > Date.now()) return token
      return refresh()
    },
    expire() { token = null },
  }
}
```

The `finally` is the part reviewers skip. Without it the promise stays on the store after it
settles, and every later call returns the first token forever, including after it expires. With a
`then` instead of a `finally`, a failed refresh leaves a rejected promise in place and every
subsequent caller inherits that failure rather than retrying.

Assigning `inFlight` before any await matters as much. JavaScript runs each turn of the event loop
to completion, so a synchronous assignment cannot be interleaved. Move the assignment after an
await and two callers can both pass the check.

## Give expiry a margin

Comparing `expiresAt` against the current time treats a token that expires in four milliseconds as
valid, and it will not be valid when the request arrives. Subtract a margin from the issuer's
`expires_in` when you store it, thirty seconds for a five-minute token, and the store refuses a
token that is technically alive and practically finished.

The margin also absorbs clock skew, which is why it belongs here rather than in each caller. Your
clock and the issuer's disagree by some amount you do not control, and
[the OAuth security guidance](https://www.rfc-editor.org/rfc/rfc9700) assumes callers handle that
rather than assuming the two agree.

## Check it worked

Count requests at the token endpoint. The client cannot tell one refresh from ten, because all ten
return a usable token, so the assertion belongs on the server side of the exchange.

```ts title="refresh.test.mjs"
test('ten callers that all see an expired token cause one refresh', async () => {
  const store = makeTokenStore({ fetchToken: makeFetchToken(api.url) })

  const tokens = await Promise.all(Array.from({ length: 10 }, () => store.get()))

  assert.equal(api.state.tokenRequests, 1, 'the token endpoint was called once')
  assert.equal(new Set(tokens.map((t) => t.value)).size, 1, 'all ten got the same token')
})
```

```bash
node --test refresh.test.mjs
```

```text output
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 346.120136
```

The second test expires the token and asks again, because a store that refreshes once and never
again passes the first test perfectly.

## When it goes wrong

Remove the shared promise and the failure is not an error, which is what makes it expensive. Both
stores below serve ten concurrent callers against the same token endpoint.

```bash
node pitfall.mjs
```

```text output
single-flight refresh
  token endpoint calls: 1
  distinct tokens held: 1
refresh per caller
  token endpoint calls: 10
  distinct tokens held: 10
```

Ten callers, ten tokens, and no exception anywhere. On an issuer that rotates refresh tokens each
use, nine of those exchanges invalidate the credential the tenth is holding, and the failure surfaces
minutes later as an unexplained 401.

The second failure mode is a refresh storm across processes. The shared promise is per process, so
sixteen workers restarting together make sixteen calls however careful each one is. Move the
credential into a store the workers share, or stagger their starts, and treat the in-process guard
as covering only what it covers.

## When not to do this

Do not add a shared promise to a store that is already serialized. A single-threaded worker that
makes one call at a time gains nothing from it and gains a variable that has to be reset correctly.

Do not cache the promise across a credential change. Rotating a client secret while a refresh is in flight leaves callers awaiting a request made with the old secret. Clear the store when the configuration reloads rather than letting the pending promise resolve.

Do not use this to paper over a token lifetime that is too short for your traffic. A credential that
expires every sixty seconds under steady load is a configuration problem, and a refresh guard turns
a visible storm into a steady drip against the same endpoint.

Do not keep the refresh logic and the retry logic in one function. A 401 that follows a successful refresh is a scope or tenancy failure rather than an expiry. A loop that refreshes on every 401 turns that into an endless exchange with the issuer.

## Related how-tos

- [Add a bearer token to fetch without a client library](/howto/bearer-token-fetch-wrapper)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

## Last verified

Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed,
against a local token endpoint that counts requests rather than against a live issuer.