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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A shared 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 | 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 | 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 | 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.
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.
Related how-tos
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.