How-to › Authenticate and authorize calls

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.

Audience
API consumer
Level
advanced
Topic
Send API keys and bearer tokens
Languages
TypeScript and JavaScript
Verified

Read first: Add a bearer token to fetch without a client library

Traffic arrives in bursts, and the token expires between two of them. Ten in-flight calls each read the store, each find the token stale, and each start a refresh. The token endpoint sees ten requests for one credential and answers some of them with a rate limit. Nine of your callers now hold a token that was replaced before they used it.

What you get

You will end up with a token store where concurrent callers share one refresh, and a test that counts requests at the token endpoint to prove it. This is for you if a burst of traffic can outrun a single credential.

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. Nothing in the pattern depends on the grant. A session cookie or a signed assertion with a lifetime has the same problem.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A shared promiseOne process holding one credentialCoordination stops at the process boundary, so eight workers still make eight callsMany processes share one credential and the endpoint counts them together
async-mutexYou already lock other shared resources the same wayA dependency, and a lock you have to release on every path including the throwing onesThe only thing you serialize is the refresh
Refreshing earlyThe token has a known lifetime and the clock is trustworthyClock skew between you and the issuer turns an early refresh into a late oneThe issuer returns no expiry, or a very short one
A refresh per callerA script making one call at a timeNothing at low volume, then everything at once when concurrency arrivesTwo 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.

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 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.

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')
})
node --test refresh.test.mjs
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.

node pitfall.mjs
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.

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.

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.